Skip to main content

pacsea/sources/
aur_vote.rs

1//! AUR package voting via SSH.
2//!
3//! ## Contract (verified from aurweb v6.3.4)
4//!
5//! **Transport**: `ssh aur@aur.archlinux.org {vote|unvote} <pkgbase>`
6//!
7//! **Auth**: User's SSH public key must be uploaded to their AUR account.
8//! No passwords, cookies, or tokens are involved.
9//!
10//! **Success**: exit code 0, empty stdout/stderr.
11//!
12//! **Failure signals** (exit code 1, message on stderr):
13//! - `"already voted for package base: {name}"` — duplicate vote
14//! - `"missing vote for package base: {name}"` — unvote when not voted
15//! - `"package base not found: {name}"` — invalid pkgbase
16//! - `"The AUR is down due to maintenance"` — maintenance window
17//! - `"The SSH interface is disabled for your IP address"` — IP ban
18//!
19//! **SSH-level failures**: exit code 255 for auth/connection issues.
20//!
21//! See `dev/ROADMAP/PRIORITY_ssh_aur_voting.md` "Phase 0 verified contract" for
22//! the full mapping table.
23
24use std::fmt;
25use std::process::{Command, Output, Stdio};
26
27/// AUR host used for SSH voting commands.
28const AUR_SSH_HOST: &str = "aur@aur.archlinux.org";
29
30// ---------------------------------------------------------------------------
31// Types
32// ---------------------------------------------------------------------------
33
34/// What: Voting action to perform on an AUR package base.
35///
36/// Details:
37/// - `Vote` adds a vote for the package base.
38/// - `Unvote` removes an existing vote.
39/// - AUR does not support downvotes.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum VoteAction {
42    /// Add a vote for the package base.
43    Vote,
44    /// Remove an existing vote from the package base.
45    Unvote,
46}
47
48impl VoteAction {
49    /// What: Return the SSH subcommand string for this action.
50    ///
51    /// Output:
52    /// - `"vote"` or `"unvote"`.
53    const fn as_ssh_arg(self) -> &'static str {
54        match self {
55            Self::Vote => "vote",
56            Self::Unvote => "unvote",
57        }
58    }
59}
60
61impl fmt::Display for VoteAction {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Vote => write!(f, "Vote"),
65            Self::Unvote => write!(f, "Unvote"),
66        }
67    }
68}
69
70/// What: Live vote-state of the current user for one AUR package base.
71///
72/// Details:
73/// - `Voted` means the account currently has an active vote.
74/// - `NotVoted` means no vote exists for the package base.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum AurPackageVoteState {
77    /// User has voted for this package base.
78    Voted,
79    /// User has not voted for this package base.
80    NotVoted,
81}
82
83/// What: Successful outcome of an AUR vote operation.
84///
85/// Details:
86/// - Carries the action performed and the target package base name.
87/// - `message()` returns a user-facing confirmation string.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct AurVoteOutcome {
90    /// The action that was performed.
91    pub action: VoteAction,
92    /// The package base name that was voted on.
93    pub pkgbase: String,
94    /// Whether this was a dry-run (simulated) operation.
95    pub dry_run: bool,
96}
97
98impl AurVoteOutcome {
99    /// What: Build a user-facing message describing the outcome.
100    ///
101    /// Output:
102    /// - A human-readable string suitable for toast/modal display.
103    #[must_use]
104    pub fn message(&self) -> String {
105        if self.dry_run {
106            return match self.action {
107                VoteAction::Vote => {
108                    format!("[dry-run] Would vote for '{}'", self.pkgbase)
109                }
110                VoteAction::Unvote => {
111                    format!("[dry-run] Would remove vote for '{}'", self.pkgbase)
112                }
113            };
114        }
115        match self.action {
116            VoteAction::Vote => format!("Voted for '{}'", self.pkgbase),
117            VoteAction::Unvote => format!("Removed vote for '{}'", self.pkgbase),
118        }
119    }
120}
121
122/// What: Typed error variants for AUR vote failures.
123///
124/// Details:
125/// - Each variant maps to a specific upstream failure signal.
126/// - `Display` impl produces user-facing actionable messages.
127#[derive(Clone, Debug, PartialEq, Eq)]
128pub enum AurVoteError {
129    /// User has already voted for this package base.
130    AlreadyVoted(String),
131    /// User has not voted for this package base (cannot unvote).
132    NotVoted(String),
133    /// Package base does not exist on AUR.
134    NotFound(String),
135    /// SSH key authentication failed.
136    AuthFailed(String),
137    /// AUR is under maintenance.
138    Maintenance,
139    /// SSH interface is disabled for the user's IP address.
140    Banned,
141    /// Connection timed out.
142    Timeout(String),
143    /// Network/DNS resolution failure.
144    NetworkError(String),
145    /// SSH binary was not found on the system.
146    SshNotFound(String),
147    /// Unexpected error with raw stderr.
148    Unexpected(String),
149}
150
151impl fmt::Display for AurVoteError {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        match self {
154            Self::AlreadyVoted(pkg) => {
155                write!(f, "You have already voted for '{pkg}'")
156            }
157            Self::NotVoted(pkg) => {
158                write!(f, "You haven't voted for '{pkg}'")
159            }
160            Self::NotFound(pkg) => {
161                write!(f, "Package base '{pkg}' not found on AUR")
162            }
163            Self::AuthFailed(detail) => {
164                write!(
165                    f,
166                    "SSH auth failed. Ensure your SSH key is uploaded to your AUR account \
167                     at https://aur.archlinux.org/account ({detail})"
168                )
169            }
170            Self::Maintenance => {
171                write!(f, "AUR is under maintenance. Try again later")
172            }
173            Self::Banned => {
174                write!(f, "SSH interface disabled for your IP. Contact AUR support")
175            }
176            Self::Timeout(detail) => {
177                write!(
178                    f,
179                    "Connection to aur.archlinux.org timed out. Check network ({detail})"
180                )
181            }
182            Self::NetworkError(detail) => {
183                write!(
184                    f,
185                    "Could not connect to aur.archlinux.org. Check connectivity ({detail})"
186                )
187            }
188            Self::SshNotFound(cmd) => {
189                write!(
190                    f,
191                    "SSH binary '{cmd}' not found. Install openssh or configure \
192                     aur_vote_ssh_command in settings"
193                )
194            }
195            Self::Unexpected(detail) => {
196                write!(f, "AUR vote failed unexpectedly: {detail}")
197            }
198        }
199    }
200}
201
202impl std::error::Error for AurVoteError {}
203
204/// What: Configuration context for an AUR vote operation.
205///
206/// Details:
207/// - `dry_run`: when true, no SSH subprocess is spawned.
208/// - `ssh_timeout_secs`: passed as `-o ConnectTimeout=N` to SSH.
209/// - `ssh_command`: path or name of the SSH binary (default: `"ssh"`).
210#[derive(Clone, Debug)]
211pub struct AurVoteContext {
212    /// If true, simulate the vote without network activity.
213    pub dry_run: bool,
214    /// SSH connect timeout in seconds.
215    pub ssh_timeout_secs: u32,
216    /// SSH binary path or name.
217    pub ssh_command: String,
218}
219
220impl Default for AurVoteContext {
221    fn default() -> Self {
222        Self {
223            dry_run: false,
224            ssh_timeout_secs: 10,
225            ssh_command: "ssh".to_string(),
226        }
227    }
228}
229
230// ---------------------------------------------------------------------------
231// Transport trait + implementations
232// ---------------------------------------------------------------------------
233
234/// What: Abstraction over SSH subprocess execution for testability.
235///
236/// Details:
237/// - `RealSshTransport` spawns the actual SSH process.
238/// - Test code uses `MockSshTransport` to return configurable results.
239trait SshVoteTransport {
240    /// What: Execute an SSH vote/unvote command.
241    ///
242    /// Inputs:
243    /// - `action`: vote or unvote.
244    /// - `pkgbase`: target package base name.
245    /// - `ctx`: configuration (SSH binary, timeout).
246    ///
247    /// Output:
248    /// - `Ok(Output)` on process completion (even if exit != 0).
249    /// - `Err` if the process could not be spawned.
250    fn execute(
251        &self,
252        action: VoteAction,
253        pkgbase: &str,
254        ctx: &AurVoteContext,
255    ) -> std::io::Result<Output>;
256}
257
258/// What: Real SSH transport that spawns `ssh aur@aur.archlinux.org`.
259///
260/// Details:
261/// - Uses `-o BatchMode=yes` to prevent interactive password prompts.
262/// - Uses `-o ConnectTimeout=N` to bound connection time.
263/// - stdin is null; stdout and stderr are piped for capture.
264struct RealSshTransport;
265
266impl SshVoteTransport for RealSshTransport {
267    fn execute(
268        &self,
269        action: VoteAction,
270        pkgbase: &str,
271        ctx: &AurVoteContext,
272    ) -> std::io::Result<Output> {
273        let timeout_arg = format!("ConnectTimeout={}", ctx.ssh_timeout_secs);
274        Command::new(&ctx.ssh_command)
275            .args([
276                "-o",
277                &timeout_arg,
278                "-o",
279                "BatchMode=yes",
280                AUR_SSH_HOST,
281                action.as_ssh_arg(),
282                pkgbase,
283            ])
284            .stdin(Stdio::null())
285            .stdout(Stdio::piped())
286            .stderr(Stdio::piped())
287            .output()
288    }
289}
290
291// ---------------------------------------------------------------------------
292// SSH result parsing
293// ---------------------------------------------------------------------------
294
295/// SSH exit code 255 indicates an SSH-level failure (auth, connection, etc.).
296const SSH_ERROR_EXIT_CODE: i32 = 255;
297/// aurweb stderr fragment indicating unsupported `list-votes` command.
298const LIST_VOTES_UNSUPPORTED_PATTERN: &str = "invalid command: list-votes";
299
300/// What: Parse SSH subprocess output into a typed result.
301///
302/// Inputs:
303/// - `output`: captured process output (exit code, stdout, stderr).
304/// - `action`: the vote action that was attempted.
305/// - `pkgbase`: the target package base name.
306///
307/// Output:
308/// - `Ok(AurVoteOutcome)` on success (exit 0).
309/// - `Err(AurVoteError)` with a specific variant for each failure mode.
310///
311/// Details:
312/// - Exit 0 is the only success signal.
313/// - Exit 1 with known stderr patterns maps to specific error variants.
314/// - Exit 255 is an SSH-level auth/connection failure.
315/// - Other failures are matched by stderr content, then fall through to
316///   `Unexpected`.
317fn parse_ssh_result(
318    output: &Output,
319    action: VoteAction,
320    pkgbase: &str,
321) -> Result<AurVoteOutcome, AurVoteError> {
322    let exit_code = output.status.code().unwrap_or(-1);
323    let stderr = String::from_utf8_lossy(&output.stderr);
324    let stderr_trimmed = stderr.trim();
325
326    if exit_code == 0 {
327        return Ok(AurVoteOutcome {
328            action,
329            pkgbase: pkgbase.to_string(),
330            dry_run: false,
331        });
332    }
333
334    if exit_code == 1 {
335        if stderr_trimmed.contains("already voted for package base") {
336            return Err(AurVoteError::AlreadyVoted(pkgbase.to_string()));
337        }
338        if stderr_trimmed.contains("missing vote for package base") {
339            return Err(AurVoteError::NotVoted(pkgbase.to_string()));
340        }
341        if stderr_trimmed.contains("package base not found") {
342            return Err(AurVoteError::NotFound(pkgbase.to_string()));
343        }
344        if stderr_trimmed.contains("AUR is down due to maintenance") {
345            return Err(AurVoteError::Maintenance);
346        }
347        if stderr_trimmed.contains("SSH interface is disabled") {
348            return Err(AurVoteError::Banned);
349        }
350    }
351
352    // Check timeout/network patterns before generic SSH 255, since SSH
353    // connection failures also exit 255 but carry distinguishable stderr.
354    if stderr_trimmed.contains("Connection timed out")
355        || stderr_trimmed.contains("Connection refused")
356    {
357        return Err(AurVoteError::Timeout(sanitize_stderr(stderr_trimmed)));
358    }
359
360    if stderr_trimmed.contains("Could not resolve hostname")
361        || stderr_trimmed.contains("Network is unreachable")
362        || stderr_trimmed.contains("No route to host")
363    {
364        return Err(AurVoteError::NetworkError(sanitize_stderr(stderr_trimmed)));
365    }
366
367    if exit_code == SSH_ERROR_EXIT_CODE {
368        return Err(AurVoteError::AuthFailed(sanitize_stderr(stderr_trimmed)));
369    }
370
371    Err(AurVoteError::Unexpected(sanitize_stderr(stderr_trimmed)))
372}
373
374/// What: Parse `list-votes` SSH output into a package vote-state result.
375///
376/// Inputs:
377/// - `output`: Captured process output for `ssh aur@aur.archlinux.org list-votes`.
378/// - `pkgbase`: Package base to resolve against the returned vote list.
379///
380/// Output:
381/// - `Ok(AurPackageVoteState::Voted)` when `pkgbase` appears in the vote list.
382/// - `Ok(AurPackageVoteState::NotVoted)` when command succeeds but package is absent.
383/// - `Err(AurVoteError)` for auth/network/maintenance and other failures.
384///
385/// Details:
386/// - Successful `list-votes` output is parsed as whitespace-delimited package names.
387/// - Error mapping reuses existing vote-flow error variants for consistent UX handling.
388fn parse_list_votes_result(
389    output: &Output,
390    pkgbase: &str,
391) -> Result<AurPackageVoteState, AurVoteError> {
392    let exit_code = output.status.code().unwrap_or(-1);
393    let stderr = String::from_utf8_lossy(&output.stderr);
394    let stderr_trimmed = stderr.trim();
395
396    if exit_code == 0 {
397        let stdout = String::from_utf8_lossy(&output.stdout);
398        let is_voted = stdout.split_whitespace().any(|name| name == pkgbase);
399        return Ok(if is_voted {
400            AurPackageVoteState::Voted
401        } else {
402            AurPackageVoteState::NotVoted
403        });
404    }
405
406    if stderr_trimmed.contains("AUR is down due to maintenance") {
407        return Err(AurVoteError::Maintenance);
408    }
409    if stderr_trimmed.contains(LIST_VOTES_UNSUPPORTED_PATTERN) {
410        return Err(AurVoteError::Unexpected(
411            "AUR SSH server does not support vote-state lookup.".to_string(),
412        ));
413    }
414    if stderr_trimmed.contains("SSH interface is disabled") {
415        return Err(AurVoteError::Banned);
416    }
417    if stderr_trimmed.contains("Connection timed out")
418        || stderr_trimmed.contains("Connection refused")
419    {
420        return Err(AurVoteError::Timeout(sanitize_stderr(stderr_trimmed)));
421    }
422    if stderr_trimmed.contains("Could not resolve hostname")
423        || stderr_trimmed.contains("Network is unreachable")
424        || stderr_trimmed.contains("No route to host")
425    {
426        return Err(AurVoteError::NetworkError(sanitize_stderr(stderr_trimmed)));
427    }
428    if exit_code == SSH_ERROR_EXIT_CODE {
429        return Err(AurVoteError::AuthFailed(sanitize_stderr(stderr_trimmed)));
430    }
431
432    Err(AurVoteError::Unexpected(sanitize_stderr(stderr_trimmed)))
433}
434
435/// What: Sanitize SSH stderr output before including in user-facing errors.
436///
437/// Inputs:
438/// - `raw`: raw stderr text from the SSH subprocess.
439///
440/// Output:
441/// - Cleaned string with sensitive paths redacted and length bounded.
442///
443/// Details:
444/// - Removes lines containing identity file paths (`/home/.../.ssh/`).
445/// - Truncates excessively long output to keep error messages readable.
446fn sanitize_stderr(raw: &str) -> String {
447    const MAX_LEN: usize = 200;
448    let filtered: String = raw
449        .lines()
450        .filter(|line| !line.contains("/.ssh/") && !line.contains("identity file"))
451        .collect::<Vec<_>>()
452        .join("; ");
453
454    if filtered.len() > MAX_LEN {
455        format!("{}...", &filtered[..MAX_LEN])
456    } else {
457        filtered
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Public API
463// ---------------------------------------------------------------------------
464
465/// What: Vote or unvote an AUR package base via SSH.
466///
467/// Inputs:
468/// - `pkgbase`: the AUR package base name (e.g. `"pacsea-bin"`).
469/// - `action`: `VoteAction::Vote` or `VoteAction::Unvote`.
470/// - `ctx`: configuration (dry-run, SSH timeout, SSH binary).
471///
472/// Output:
473/// - `Ok(AurVoteOutcome)` on success, with a user-facing `.message()`.
474/// - `Err(AurVoteError)` on failure, with a user-facing `Display` impl.
475///
476/// Details:
477/// - In dry-run mode, returns a simulated outcome without spawning SSH.
478/// - Uses `-o BatchMode=yes` to prevent interactive prompts.
479/// - Uses `-o ConnectTimeout=N` to bound connection time.
480/// - Never logs SSH key paths or identity file contents.
481///
482/// # Errors
483///
484/// Returns `AurVoteError` for auth failure, package not found, network
485/// issues, SSH binary missing, AUR maintenance, IP ban, or unexpected
486/// upstream errors. Each variant has a user-facing `Display` message.
487pub fn aur_vote(
488    pkgbase: &str,
489    action: VoteAction,
490    ctx: &AurVoteContext,
491) -> Result<AurVoteOutcome, AurVoteError> {
492    aur_vote_with_transport(&RealSshTransport, pkgbase, action, ctx)
493}
494
495/// What: Check whether the current AUR account has voted for a package base.
496///
497/// Inputs:
498/// - `pkgbase`: Target AUR package base name.
499/// - `ctx`: SSH execution context (timeout and SSH command).
500///
501/// Output:
502/// - `Ok(AurPackageVoteState)` on successful state retrieval.
503/// - `Err(AurVoteError)` for SSH/auth/network/maintenance and unexpected failures.
504///
505/// Details:
506/// - Uses `ssh aur@aur.archlinux.org list-votes` and checks membership client-side.
507/// - This is read-only and does not mutate vote state.
508///
509/// # Errors
510///
511/// Returns `AurVoteError` when SSH command execution fails, authentication is
512/// invalid, network connectivity fails, AUR is in maintenance mode, the caller
513/// IP is blocked, or upstream output cannot be mapped.
514pub fn aur_vote_state(
515    pkgbase: &str,
516    ctx: &AurVoteContext,
517) -> Result<AurPackageVoteState, AurVoteError> {
518    let timeout_arg = format!("ConnectTimeout={}", ctx.ssh_timeout_secs);
519    let output = Command::new(&ctx.ssh_command)
520        .args([
521            "-o",
522            &timeout_arg,
523            "-o",
524            "BatchMode=yes",
525            AUR_SSH_HOST,
526            "list-votes",
527        ])
528        .stdin(Stdio::null())
529        .stdout(Stdio::piped())
530        .stderr(Stdio::piped())
531        .output()
532        .map_err(|e| match e.kind() {
533            std::io::ErrorKind::NotFound => AurVoteError::SshNotFound(ctx.ssh_command.clone()),
534            _ => AurVoteError::NetworkError(e.to_string()),
535        })?;
536
537    parse_list_votes_result(&output, pkgbase)
538}
539
540/// What: Determine whether a vote-state error indicates unsupported upstream command.
541///
542/// Inputs:
543/// - `error`: Typed vote error returned by `aur_vote_state`.
544///
545/// Output:
546/// - `true` if the upstream SSH endpoint rejected `list-votes` as invalid.
547///
548/// Details:
549/// - Used by UI event-loop mapping to degrade gracefully to `Unknown` instead of
550///   showing persistent inline errors for unsupported read-only lookups.
551#[must_use]
552pub fn is_vote_state_unsupported_error(error: &AurVoteError) -> bool {
553    match error {
554        AurVoteError::Unexpected(detail) => detail.contains("does not support vote-state lookup"),
555        _ => false,
556    }
557}
558
559/// What: Internal entry point parameterised on transport for testability.
560///
561/// Inputs:
562/// - `transport`: the SSH transport implementation.
563/// - `pkgbase`: the AUR package base name.
564/// - `action`: vote or unvote.
565/// - `ctx`: configuration context.
566///
567/// Output:
568/// - Same as `aur_vote`.
569///
570/// Details:
571/// - Dry-run is checked before transport is invoked.
572/// - `io::Error` from subprocess spawn is mapped to `SshNotFound` or `NetworkError`.
573fn aur_vote_with_transport<T: SshVoteTransport>(
574    transport: &T,
575    pkgbase: &str,
576    action: VoteAction,
577    ctx: &AurVoteContext,
578) -> Result<AurVoteOutcome, AurVoteError> {
579    if ctx.dry_run {
580        return Ok(AurVoteOutcome {
581            action,
582            pkgbase: pkgbase.to_string(),
583            dry_run: true,
584        });
585    }
586
587    let output = transport
588        .execute(action, pkgbase, ctx)
589        .map_err(|e| match e.kind() {
590            std::io::ErrorKind::NotFound => AurVoteError::SshNotFound(ctx.ssh_command.clone()),
591            _ => AurVoteError::NetworkError(e.to_string()),
592        })?;
593
594    parse_ssh_result(&output, action, pkgbase)
595}
596
597// ---------------------------------------------------------------------------
598// Tests
599// ---------------------------------------------------------------------------
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    #[cfg(unix)]
605    use std::os::unix::process::ExitStatusExt;
606    #[cfg(windows)]
607    use std::os::windows::process::ExitStatusExt;
608    use std::process::ExitStatus;
609
610    fn exit_status_from_code(exit_code: i32) -> ExitStatus {
611        #[cfg(unix)]
612        {
613            ExitStatus::from_raw(exit_code << 8)
614        }
615
616        #[cfg(windows)]
617        {
618            ExitStatus::from_raw(exit_code.cast_unsigned())
619        }
620    }
621
622    /// Mock transport that returns a configurable exit code and stderr.
623    struct MockSshTransport {
624        exit_code: i32,
625        stderr: String,
626    }
627
628    impl MockSshTransport {
629        fn new(exit_code: i32, stderr: &str) -> Self {
630            Self {
631                exit_code,
632                stderr: stderr.to_string(),
633            }
634        }
635    }
636
637    impl SshVoteTransport for MockSshTransport {
638        fn execute(
639            &self,
640            _action: VoteAction,
641            _pkgbase: &str,
642            _ctx: &AurVoteContext,
643        ) -> std::io::Result<Output> {
644            Ok(Output {
645                status: exit_status_from_code(self.exit_code),
646                stdout: Vec::new(),
647                stderr: self.stderr.as_bytes().to_vec(),
648            })
649        }
650    }
651
652    /// Mock transport that returns an `io::Error` on execute.
653    struct FailingTransport {
654        kind: std::io::ErrorKind,
655    }
656
657    impl SshVoteTransport for FailingTransport {
658        fn execute(
659            &self,
660            _action: VoteAction,
661            _pkgbase: &str,
662            _ctx: &AurVoteContext,
663        ) -> std::io::Result<Output> {
664            Err(std::io::Error::new(self.kind, "mock io error"))
665        }
666    }
667
668    fn default_ctx() -> AurVoteContext {
669        AurVoteContext::default()
670    }
671
672    fn dry_run_ctx() -> AurVoteContext {
673        AurVoteContext {
674            dry_run: true,
675            ..AurVoteContext::default()
676        }
677    }
678
679    #[test]
680    fn test_dry_run_vote() {
681        let transport = MockSshTransport::new(0, "");
682        let result =
683            aur_vote_with_transport(&transport, "pacsea-bin", VoteAction::Vote, &dry_run_ctx());
684        let outcome = result.expect("dry-run vote should succeed");
685        assert!(outcome.dry_run);
686        assert_eq!(outcome.action, VoteAction::Vote);
687        assert!(outcome.message().contains("[dry-run]"));
688        assert!(outcome.message().contains("pacsea-bin"));
689    }
690
691    #[test]
692    fn test_dry_run_unvote() {
693        let transport = MockSshTransport::new(0, "");
694        let result =
695            aur_vote_with_transport(&transport, "pacsea-bin", VoteAction::Unvote, &dry_run_ctx());
696        let outcome = result.expect("dry-run unvote should succeed");
697        assert!(outcome.dry_run);
698        assert_eq!(outcome.action, VoteAction::Unvote);
699        assert!(outcome.message().contains("[dry-run]"));
700        assert!(outcome.message().contains("remove vote"));
701    }
702
703    #[test]
704    fn test_success_vote() {
705        let transport = MockSshTransport::new(0, "");
706        let result =
707            aur_vote_with_transport(&transport, "yay-bin", VoteAction::Vote, &default_ctx());
708        let outcome = result.expect("success vote should return outcome");
709        assert!(!outcome.dry_run);
710        assert_eq!(outcome.action, VoteAction::Vote);
711        assert_eq!(outcome.pkgbase, "yay-bin");
712        assert!(outcome.message().contains("Voted for"));
713    }
714
715    #[test]
716    fn test_success_unvote() {
717        let transport = MockSshTransport::new(0, "");
718        let result =
719            aur_vote_with_transport(&transport, "yay-bin", VoteAction::Unvote, &default_ctx());
720        let outcome = result.expect("success unvote should return outcome");
721        assert_eq!(outcome.action, VoteAction::Unvote);
722        assert!(outcome.message().contains("Removed vote"));
723    }
724
725    #[test]
726    fn test_already_voted() {
727        let transport = MockSshTransport::new(1, "vote: already voted for package base: yay-bin\n");
728        let result =
729            aur_vote_with_transport(&transport, "yay-bin", VoteAction::Vote, &default_ctx());
730        match result {
731            Err(AurVoteError::AlreadyVoted(pkg)) => assert_eq!(pkg, "yay-bin"),
732            other => panic!("expected AlreadyVoted, got {other:?}"),
733        }
734    }
735
736    #[test]
737    fn test_not_voted() {
738        let transport =
739            MockSshTransport::new(1, "unvote: missing vote for package base: yay-bin\n");
740        let result =
741            aur_vote_with_transport(&transport, "yay-bin", VoteAction::Unvote, &default_ctx());
742        match result {
743            Err(AurVoteError::NotVoted(pkg)) => assert_eq!(pkg, "yay-bin"),
744            other => panic!("expected NotVoted, got {other:?}"),
745        }
746    }
747
748    #[test]
749    fn test_package_not_found() {
750        let transport = MockSshTransport::new(1, "vote: package base not found: nonexistent-pkg\n");
751        let result = aur_vote_with_transport(
752            &transport,
753            "nonexistent-pkg",
754            VoteAction::Vote,
755            &default_ctx(),
756        );
757        match result {
758            Err(AurVoteError::NotFound(pkg)) => assert_eq!(pkg, "nonexistent-pkg"),
759            other => panic!("expected NotFound, got {other:?}"),
760        }
761    }
762
763    #[test]
764    fn test_auth_failure() {
765        let transport = MockSshTransport::new(
766            255,
767            "Permission denied (publickey).\r\nfatal: Could not read from remote repository.",
768        );
769        let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
770        match result {
771            Err(AurVoteError::AuthFailed(detail)) => {
772                assert!(detail.contains("Permission denied"));
773            }
774            other => panic!("expected AuthFailed, got {other:?}"),
775        }
776    }
777
778    #[test]
779    fn test_maintenance() {
780        let transport = MockSshTransport::new(
781            1,
782            "The AUR is down due to maintenance. We will be back soon.\n",
783        );
784        let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
785        match result {
786            Err(AurVoteError::Maintenance) => {}
787            other => panic!("expected Maintenance, got {other:?}"),
788        }
789    }
790
791    #[test]
792    fn test_banned() {
793        let transport =
794            MockSshTransport::new(1, "The SSH interface is disabled for your IP address.\n");
795        let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
796        match result {
797            Err(AurVoteError::Banned) => {}
798            other => panic!("expected Banned, got {other:?}"),
799        }
800    }
801
802    #[test]
803    fn test_ssh_not_found() {
804        let transport = FailingTransport {
805            kind: std::io::ErrorKind::NotFound,
806        };
807        let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
808        match result {
809            Err(AurVoteError::SshNotFound(cmd)) => assert_eq!(cmd, "ssh"),
810            other => panic!("expected SshNotFound, got {other:?}"),
811        }
812    }
813
814    #[test]
815    fn test_timeout() {
816        let transport = MockSshTransport::new(
817            255,
818            "ssh: connect to host aur.archlinux.org port 22: Connection timed out\n",
819        );
820        let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
821        match result {
822            Err(AurVoteError::Timeout(_)) => {}
823            other => panic!("expected Timeout, got {other:?}"),
824        }
825    }
826
827    #[test]
828    fn test_network_error() {
829        let transport = MockSshTransport::new(
830            255,
831            "ssh: Could not resolve hostname aur.archlinux.org: Name or service not known\n",
832        );
833        let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
834        match result {
835            Err(AurVoteError::NetworkError(_)) => {}
836            other => panic!("expected NetworkError, got {other:?}"),
837        }
838    }
839
840    #[test]
841    fn test_unexpected_error() {
842        let transport = MockSshTransport::new(99, "something completely unexpected happened\n");
843        let result = aur_vote_with_transport(&transport, "foo", VoteAction::Vote, &default_ctx());
844        match result {
845            Err(AurVoteError::Unexpected(msg)) => {
846                assert!(msg.contains("something completely unexpected"));
847            }
848            other => panic!("expected Unexpected, got {other:?}"),
849        }
850    }
851
852    #[test]
853    fn test_sanitize_stderr_redacts_ssh_paths() {
854        let raw = "debug1: Offering public key: /home/user/.ssh/id_ed25519\n\
855                   Permission denied (publickey).";
856        let sanitized = sanitize_stderr(raw);
857        assert!(!sanitized.contains("/.ssh/"));
858        assert!(sanitized.contains("Permission denied"));
859    }
860
861    #[test]
862    fn test_sanitize_stderr_truncates_long_output() {
863        let raw = "x".repeat(500);
864        let sanitized = sanitize_stderr(&raw);
865        assert!(sanitized.len() <= 203); // 200 + "..."
866        assert!(sanitized.ends_with("..."));
867    }
868
869    #[test]
870    fn test_vote_action_display() {
871        assert_eq!(format!("{}", VoteAction::Vote), "Vote");
872        assert_eq!(format!("{}", VoteAction::Unvote), "Unvote");
873    }
874
875    #[test]
876    fn test_vote_action_ssh_arg() {
877        assert_eq!(VoteAction::Vote.as_ssh_arg(), "vote");
878        assert_eq!(VoteAction::Unvote.as_ssh_arg(), "unvote");
879    }
880
881    #[test]
882    fn test_error_display_messages() {
883        let err = AurVoteError::AlreadyVoted("foo".into());
884        let msg = format!("{err}");
885        assert!(msg.contains("already voted"));
886        assert!(msg.contains("foo"));
887
888        let err = AurVoteError::SshNotFound("ssh".into());
889        let msg = format!("{err}");
890        assert!(msg.contains("not found"));
891        assert!(msg.contains("openssh"));
892    }
893
894    #[test]
895    fn test_context_default() {
896        let ctx = AurVoteContext::default();
897        assert!(!ctx.dry_run);
898        assert_eq!(ctx.ssh_timeout_secs, 10);
899        assert_eq!(ctx.ssh_command, "ssh");
900    }
901
902    #[test]
903    fn test_parse_list_votes_result_voted() {
904        let output = Output {
905            status: exit_status_from_code(0),
906            stdout: b"pacsea-bin\nyay-bin\n".to_vec(),
907            stderr: Vec::new(),
908        };
909        let state = parse_list_votes_result(&output, "pacsea-bin")
910            .expect("list-votes parsing should succeed");
911        assert_eq!(state, AurPackageVoteState::Voted);
912    }
913
914    #[test]
915    fn test_parse_list_votes_result_not_voted() {
916        let output = Output {
917            status: exit_status_from_code(0),
918            stdout: b"yay-bin\nparu-bin\n".to_vec(),
919            stderr: Vec::new(),
920        };
921        let state = parse_list_votes_result(&output, "pacsea-bin")
922            .expect("list-votes parsing should succeed");
923        assert_eq!(state, AurPackageVoteState::NotVoted);
924    }
925
926    #[test]
927    fn test_parse_list_votes_result_auth_failed() {
928        let output = Output {
929            status: exit_status_from_code(255),
930            stdout: Vec::new(),
931            stderr: b"Permission denied (publickey).".to_vec(),
932        };
933        let result = parse_list_votes_result(&output, "pacsea-bin");
934        match result {
935            Err(AurVoteError::AuthFailed(detail)) => {
936                assert!(detail.contains("Permission denied"));
937            }
938            other => panic!("expected AuthFailed, got {other:?}"),
939        }
940    }
941
942    #[test]
943    fn test_parse_list_votes_result_unsupported_command() {
944        let output = Output {
945            status: exit_status_from_code(1),
946            stdout: Vec::new(),
947            stderr: b"list-votes: invalid command: list-votes".to_vec(),
948        };
949        let result = parse_list_votes_result(&output, "pacsea-bin");
950        match result {
951            Err(AurVoteError::Unexpected(detail)) => {
952                assert!(detail.contains("does not support vote-state lookup"));
953            }
954            other => panic!("expected Unexpected unsupported-command error, got {other:?}"),
955        }
956    }
957
958    #[test]
959    fn test_aur_vote_state_missing_ssh_binary_error() {
960        let ctx = AurVoteContext {
961            dry_run: false,
962            ssh_timeout_secs: 10,
963            ssh_command: "__pacsea_missing_ssh__".to_string(),
964        };
965        let result = aur_vote_state("pacsea-bin", &ctx);
966        match result {
967            Err(AurVoteError::SshNotFound(cmd)) => {
968                assert_eq!(cmd, "__pacsea_missing_ssh__");
969            }
970            other => panic!("expected SshNotFound, got {other:?}"),
971        }
972    }
973}