diff --git a/Cargo.toml b/Cargo.toml index 4fc41d8..483eef0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "quicommit" -version = "0.3.2" +version = "0.4.0" edition = "2024" authors = ["Sidney Zhang "] description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)" diff --git a/RAODMAP.md b/RAODMAP.md index ef36a55..a6cbedf 100644 --- a/RAODMAP.md +++ b/RAODMAP.md @@ -34,12 +34,6 @@ - 支持 `quicommit credential get|store|erase` 子命令 - 与系统密钥环无缝对接,复用已有的 `KeyringManager` -- [ ] **凭证管理 CLI** - - `quicommit credential list` — 列出所有已存储的凭证 - - `quicommit credential add` — 手动添加凭证(用户名 + 密码/Token) - - `quicommit credential remove` — 删除指定凭证 - - `quicommit credential status` — 查看凭证管理状态 - - [ ] **跨平台支持** - Windows:集成 Windows Credential Manager - macOS:集成 Keychain diff --git a/src/commands/credential.rs b/src/commands/credential.rs new file mode 100644 index 0000000..a418c83 --- /dev/null +++ b/src/commands/credential.rs @@ -0,0 +1,458 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +use crate::config::manager::ConfigManager; +use crate::config::{TokenConfig, TokenType}; + +/// Git credential helper command. +/// +/// Implements the git credential helper protocol +/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git +/// via `credential.helper` configuration, not by end users. Hidden from the +/// main help output. +#[derive(Parser)] +#[command(hide = true)] +pub struct CredentialCommand { + #[command(subcommand)] + command: CredentialSubcommand, +} + +#[derive(Subcommand)] +enum CredentialSubcommand { + /// Read attributes on stdin and output credentials on stdout. + #[command(hide = true)] + Get, + /// Read attributes (including password) on stdin and store them. + #[command(hide = true)] + Store, + /// Read attributes on stdin and erase any matching stored credentials. + #[command(hide = true)] + Erase, +} + +impl CredentialCommand { + pub async fn execute(&self, config_path: Option) -> Result<()> { + match &self.command { + CredentialSubcommand::Get => Self::get(config_path), + CredentialSubcommand::Store => Self::store(config_path), + CredentialSubcommand::Erase => Self::erase(config_path), + } + } + + fn get_manager(config_path: &Option) -> Result { + match config_path { + Some(path) => ConfigManager::with_path(path), + None => ConfigManager::new(), + } + } + + /// `git credential get`: look up a PAT for the requested host and emit it + /// on stdout following the git credential helper protocol. + fn get(config_path: Option) -> Result<()> { + let attrs = CredentialAttributes::from_stdin()?; + + let host = match attrs.host.as_deref() { + Some(h) if !h.is_empty() => h, + _ => return Ok(()), + }; + let service = host_to_service(host); + + let manager = match Self::get_manager(&config_path) { + Ok(m) => m, + Err(_) => return Ok(()), + }; + + let (profile_name, pat) = match find_pat_for_service(&manager, &service) { + Some(tuple) => tuple, + None => return Ok(()), + }; + + // Prefer a git-supplied username; fall back to the profile's user_name. + let username = attrs.username.clone().or_else(|| { + manager + .get_profile(&profile_name) + .map(|p| p.user_name.clone()) + }); + + let output = CredentialAttributes { + protocol: attrs.protocol.clone(), + host: attrs.host.clone(), + path: attrs.path.clone(), + username, + password: Some(pat), + }; + output.to_stdout()?; + Ok(()) + } + + /// `git credential store`: persist the PAT provided by git using the + /// existing keyring-backed storage, associated with a matching profile. + fn store(config_path: Option) -> Result<()> { + let attrs = CredentialAttributes::from_stdin()?; + + let host = match attrs.host.as_deref() { + Some(h) if !h.is_empty() => h, + _ => return Ok(()), + }; + let service = host_to_service(host); + + let password = match attrs.password.as_deref() { + Some(p) if !p.is_empty() => p, + _ => return Ok(()), + }; + + let mut manager = Self::get_manager(&config_path)?; + + let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) { + Some(name) => name, + None => return Ok(()), + }; + + // Store PAT in keyring using the existing profile-bound logic. + if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) { + eprintln!("[quicommit credential] failed to store PAT: {}", e); + return Ok(()); + } + + // Register the token in the profile config if not already present. + let already_has = manager + .get_profile(&profile_name) + .map(|p| p.tokens.contains_key(&service)) + .unwrap_or(false); + if !already_has { + let _ = manager.add_token_to_profile( + &profile_name, + service.clone(), + TokenConfig::new(TokenType::Personal), + ); + } + + manager.save()?; + Ok(()) + } + + /// `git credential erase`: remove any PAT stored for the requested host + /// from the keyring and the associated profile config entries. + fn erase(config_path: Option) -> Result<()> { + let attrs = CredentialAttributes::from_stdin()?; + + let host = match attrs.host.as_deref() { + Some(h) if !h.is_empty() => h, + _ => return Ok(()), + }; + let service = host_to_service(host); + + let mut manager = Self::get_manager(&config_path)?; + + let profile_names: Vec = manager + .list_profiles() + .into_iter() + .filter(|name| { + manager + .get_profile(name) + .map(|p| p.tokens.contains_key(&service)) + .unwrap_or(false) + }) + .cloned() + .collect(); + + for name in &profile_names { + if let Err(e) = manager.remove_token_from_profile(name, &service) { + eprintln!( + "[quicommit credential] failed to erase PAT for '{}': {}", + name, e + ); + } + } + + manager.save()?; + Ok(()) + } +} + +/// Credential attributes exchanged with git via stdin/stdout following the +/// git credential helper protocol (`key=value`, one per line, blank line ends). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CredentialAttributes { + pub protocol: Option, + pub host: Option, + pub path: Option, + pub username: Option, + pub password: Option, +} + +impl CredentialAttributes { + /// Read attributes from stdin following the git credential helper protocol. + pub fn from_stdin() -> Result { + let stdin = io::stdin(); + let mut attrs = Self::default(); + for line in stdin.lock().lines() { + let line = + line.context("Failed to read credential attributes from stdin")?; + if line.is_empty() { + break; + } + if let Some((key, value)) = line.split_once('=') { + match key { + "protocol" => attrs.protocol = Some(value.to_string()), + "host" => attrs.host = Some(value.to_string()), + "path" => attrs.path = Some(value.to_string()), + "username" => attrs.username = Some(value.to_string()), + "password" => attrs.password = Some(value.to_string()), + _ => {} // ignore unknown keys + } + } + } + Ok(attrs) + } + + /// Write attributes to stdout following the git credential helper protocol. + pub fn to_stdout(&self) -> Result<()> { + let stdout = io::stdout(); + let mut handle = stdout.lock(); + if let Some(ref v) = self.protocol { + writeln!(handle, "protocol={}", v)?; + } + if let Some(ref v) = self.host { + writeln!(handle, "host={}", v)?; + } + if let Some(ref v) = self.path { + writeln!(handle, "path={}", v)?; + } + if let Some(ref v) = self.username { + writeln!(handle, "username={}", v)?; + } + if let Some(ref v) = self.password { + writeln!(handle, "password={}", v)?; + } + writeln!(handle)?; // blank line terminates the attribute list + handle.flush()?; + Ok(()) + } + + /// Parse attributes from a text block following the git credential helper + /// protocol. Intended for testing and non-stdin input handling. + pub fn parse_str(input: &str) -> Self { + let mut attrs = Self::default(); + for line in input.lines() { + if line.is_empty() { + break; + } + if let Some((key, value)) = line.split_once('=') { + match key { + "protocol" => attrs.protocol = Some(value.to_string()), + "host" => attrs.host = Some(value.to_string()), + "path" => attrs.path = Some(value.to_string()), + "username" => attrs.username = Some(value.to_string()), + "password" => attrs.password = Some(value.to_string()), + _ => {} + } + } + } + attrs + } + + /// Serialize attributes to a string following the git credential helper + /// protocol. Intended for testing and non-stdout output handling. + pub fn serialize(&self) -> String { + let mut out = String::new(); + if let Some(ref v) = self.protocol { + out.push_str(&format!("protocol={}\n", v)); + } + if let Some(ref v) = self.host { + out.push_str(&format!("host={}\n", v)); + } + if let Some(ref v) = self.path { + out.push_str(&format!("path={}\n", v)); + } + if let Some(ref v) = self.username { + out.push_str(&format!("username={}\n", v)); + } + if let Some(ref v) = self.password { + out.push_str(&format!("password={}\n", v)); + } + out.push('\n'); + out + } +} + +/// Map a git host to a service name used by the keyring-backed PAT storage. +/// +/// Common git hosting services are mapped to short canonical names. Unknown +/// hosts are used as-is (lowercased, trailing slash trimmed). +pub fn host_to_service(host: &str) -> String { + let host = host.to_lowercase(); + let host = host.trim_end_matches('/'); + match host { + "github.com" | "www.github.com" => "github".to_string(), + "gitlab.com" | "www.gitlab.com" => "gitlab".to_string(), + "bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(), + "codeberg.org" | "www.codeberg.org" => "codeberg".to_string(), + "gitea.com" | "www.gitea.com" => "gitea".to_string(), + "gitee.com" | "www.gitee.com" => "gitee".to_string(), + other => other.to_string(), + } +} + +/// Search all profiles for one that has a PAT stored for the given service. +/// Returns the profile name and the PAT value. +fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> { + for profile_name in manager.list_profiles() { + if manager.has_pat_for_profile(profile_name, service) { + if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) { + return Some((profile_name.clone(), pat)); + } + } + } + None +} + +/// Determine which profile to use when storing a credential. +/// +/// Prefers a profile whose `user_name` or `user_email` matches the +/// git-supplied username; otherwise falls back to the default profile. +fn find_profile_for_store( + manager: &ConfigManager, + username: Option<&str>, +) -> Option { + if let Some(username) = username { + for name in manager.list_profiles() { + if let Some(profile) = manager.get_profile(name) { + if profile.user_name == username || profile.user_email == username { + return Some(name.clone()); + } + } + } + } + manager.default_profile_name().cloned() +} + +/// Extract a PAT for the given host from saved credentials across all profiles. +/// +/// This is intended for use by other parts of the application (e.g. when +/// verifying access to a git hosting service) and searches every configured +/// profile for a stored PAT matching the host. +pub fn get_pat_for_host(host: &str) -> Result> { + let manager = ConfigManager::new()?; + let service = host_to_service(host); + Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_host_to_service_known_hosts() { + assert_eq!(host_to_service("github.com"), "github"); + assert_eq!(host_to_service("www.github.com"), "github"); + assert_eq!(host_to_service("gitlab.com"), "gitlab"); + assert_eq!(host_to_service("bitbucket.org"), "bitbucket"); + assert_eq!(host_to_service("codeberg.org"), "codeberg"); + assert_eq!(host_to_service("gitea.com"), "gitea"); + assert_eq!(host_to_service("gitee.com"), "gitee"); + } + + #[test] + fn test_host_to_service_case_insensitive() { + assert_eq!(host_to_service("GitHub.Com"), "github"); + assert_eq!(host_to_service("GITLAB.COM"), "gitlab"); + } + + #[test] + fn test_host_to_service_trailing_slash() { + assert_eq!(host_to_service("github.com/"), "github"); + assert_eq!(host_to_service("gitlab.com//"), "gitlab"); + } + + #[test] + fn test_host_to_service_unknown_host() { + assert_eq!(host_to_service("example.com"), "example.com"); + assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp"); + } + + #[test] + fn test_credential_attributes_parse_basic() { + let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + assert_eq!(attrs.username.as_deref(), Some("octocat")); + assert_eq!(attrs.password.as_deref(), Some("ghp_token123")); + assert!(attrs.path.is_none()); + } + + #[test] + fn test_credential_attributes_parse_with_path() { + let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.path.as_deref(), Some("owner/repo.git")); + } + + #[test] + fn test_credential_attributes_parse_empty() { + let attrs = CredentialAttributes::parse_str(""); + assert!(attrs.protocol.is_none()); + assert!(attrs.host.is_none()); + assert!(attrs.username.is_none()); + assert!(attrs.password.is_none()); + } + + #[test] + fn test_credential_attributes_parse_ignores_unknown_keys() { + let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + } + + #[test] + fn test_credential_attributes_parse_stops_at_blank_line() { + let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + assert!(attrs.password.is_none()); + } + + #[test] + fn test_credential_attributes_serialize_roundtrip() { + let attrs = CredentialAttributes { + protocol: Some("https".to_string()), + host: Some("github.com".to_string()), + path: None, + username: Some("octocat".to_string()), + password: Some("ghp_token".to_string()), + }; + let serialized = attrs.serialize(); + let reparsed = CredentialAttributes::parse_str(&serialized); + assert_eq!(attrs, reparsed); + } + + #[test] + fn test_credential_attributes_serialize_includes_blank_line() { + let attrs = CredentialAttributes { + protocol: Some("https".to_string()), + host: Some("github.com".to_string()), + path: None, + username: None, + password: None, + }; + let serialized = attrs.serialize(); + assert!(serialized.ends_with("\n\n")); + assert!(serialized.contains("protocol=https\n")); + assert!(serialized.contains("host=github.com\n")); + } + + #[test] + fn test_credential_attributes_default() { + let attrs = CredentialAttributes::default(); + assert!(attrs.protocol.is_none()); + assert!(attrs.host.is_none()); + assert!(attrs.path.is_none()); + assert!(attrs.username.is_none()); + assert!(attrs.password.is_none()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a14be75..f36a54b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod changelog; pub mod commit; pub mod config; +pub mod credential; pub mod init; pub mod profile; pub mod tag; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..6a53cc9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,9 @@ +#![allow(dead_code)] + +pub mod commands; +pub mod config; +pub mod generator; +pub mod git; +pub mod i18n; +pub mod llm; +pub mod utils; diff --git a/src/main.rs b/src/main.rs index 9628f7d..4dc366c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,17 +5,9 @@ use clap::{Parser, Subcommand}; use std::path::PathBuf; use tracing::debug; -mod commands; -mod config; -mod generator; -mod git; -mod i18n; -mod llm; -mod utils; - -use commands::{ - changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand, init::InitCommand, - profile::ProfileCommand, tag::TagCommand, +use quicommit::commands::{ + changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand, + credential::CredentialCommand, init::InitCommand, profile::ProfileCommand, tag::TagCommand, }; /// QuiCommit - AI-powered Git assistant @@ -71,6 +63,10 @@ enum Commands { /// Manage configuration settings #[command(alias = "cfg")] Config(ConfigCommand), + + /// Git credential helper (hidden, invoked by git) + #[command(hide = true)] + Credential(CredentialCommand), } #[tokio::main] @@ -100,5 +96,6 @@ async fn main() -> Result<()> { Commands::Changelog(cmd) => cmd.execute(config_path).await, Commands::Profile(cmd) => cmd.execute(config_path).await, Commands::Config(cmd) => cmd.execute(config_path).await, + Commands::Credential(cmd) => cmd.execute(config_path).await, } } diff --git a/src/utils/keyring.rs b/src/utils/keyring.rs index 63926a6..e5246e8 100644 --- a/src/utils/keyring.rs +++ b/src/utils/keyring.rs @@ -150,11 +150,6 @@ impl KeyringManager { .set_password(token) .context("Failed to store PAT in keyring")?; - eprintln!( - "[DEBUG] PAT stored in keyring: service={}, user={}", - keyring_service, keyring_user - ); - Ok(()) } @@ -175,20 +170,8 @@ impl KeyringManager { .context("Failed to create keyring entry for PAT")?; match entry.get_password() { - Ok(token) => { - eprintln!( - "[DEBUG] PAT retrieved from keyring: service={}, user={}", - keyring_service, keyring_user - ); - Ok(Some(token)) - } - Err(keyring::Error::NoEntry) => { - eprintln!( - "[DEBUG] PAT not found in keyring: service={}, user={}", - keyring_service, keyring_user - ); - Ok(None) - } + Ok(token) => Ok(Some(token)), + Err(keyring::Error::NoEntry) => Ok(None), Err(e) => Err(e.into()), } } @@ -208,11 +191,6 @@ impl KeyringManager { .delete_credential() .context("Failed to delete PAT from keyring")?; - eprintln!( - "[DEBUG] PAT deleted from keyring: service={}, user={}", - keyring_service, keyring_user - ); - Ok(()) } @@ -229,12 +207,7 @@ impl KeyringManager { services: &[String], ) -> Result<()> { for service in services { - if let Err(e) = self.delete_pat(profile_name, user_email, service) { - eprintln!( - "[DEBUG] Failed to delete PAT for service '{}': {}", - service, e - ); - } + let _ = self.delete_pat(profile_name, user_email, service); } Ok(()) } diff --git a/tests/credential_tests.rs b/tests/credential_tests.rs new file mode 100644 index 0000000..95e6ab9 --- /dev/null +++ b/tests/credential_tests.rs @@ -0,0 +1,1124 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use predicates::prelude::*; +use quicommit::commands::credential::{host_to_service, CredentialAttributes}; +use quicommit::config::manager::ConfigManager; +use quicommit::config::{GitProfile, TokenConfig, TokenType}; +use quicommit::utils::keyring::KeyringManager; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Global counter to generate unique profile names/emails per test invocation, +/// preventing parallel keyring interference (the system keyring is a shared +/// resource and tests run in parallel by default). +static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn unique_id() -> u64 { + UNIQUE_COUNTER.fetch_add(1, Ordering::SeqCst) +} + +/// Generate a unique profile name for a test. +fn unique_profile_name(prefix: &str) -> String { + format!("{}_{}", prefix, unique_id()) +} + +/// Generate a unique user email for a test. +fn unique_email(prefix: &str) -> String { + format!("{}@test.local", unique_profile_name(prefix)) +} + +/// Write a minimal config file with one profile so the credential helper has +/// something to work with. Returns the profile name and user email so tests +/// can use them for keyring cleanup. +fn write_config_with_profile( + config_path: &PathBuf, + profile_name: &str, + user_name: &str, + user_email: &str, +) { + let mut manager = ConfigManager::with_path_fresh(config_path).unwrap(); + let profile = GitProfile::new( + profile_name.to_string(), + user_name.to_string(), + user_email.to_string(), + ); + manager + .add_profile(profile_name.to_string(), profile) + .unwrap(); + manager + .set_default_profile(Some(profile_name.to_string())) + .unwrap(); + manager.save().unwrap(); +} + +/// Like `write_config_with_profile` but auto-generates a unique profile name +/// and email. Returns `(profile_name, user_email)` for later cleanup. +fn write_config_with_unique_profile( + config_path: &PathBuf, + prefix: &str, +) -> (String, String) { + let profile_name = unique_profile_name(prefix); + let user_email = unique_email(prefix); + write_config_with_profile( + config_path, + &profile_name, + "Test User", + &user_email, + ); + (profile_name, user_email) +} + +/// Run `quicommit credential ` with the given stdin and config. +fn run_credential( + subcommand: &str, + stdin_input: &str, + config_path: &PathBuf, +) -> std::process::Output { + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.args(["credential", subcommand, "--config", config_path.to_str().unwrap()]) + .write_stdin(stdin_input.to_string()); + cmd.output().expect("Failed to execute quicommit credential") +} + +/// Check whether the system keyring is available so we can skip tests that +/// require it. +fn keyring_available() -> bool { + KeyringManager::new().is_available() +} + +/// Remove a PAT from the keyring if it exists (cleanup helper). +fn cleanup_pat(profile_name: &str, user_email: &str, service: &str) { + let km = KeyringManager::new(); + if km.is_available() { + let _ = km.delete_pat(profile_name, user_email, service); + } +} + +// --------------------------------------------------------------------------- +// Unit tests for host_to_service +// --------------------------------------------------------------------------- + +mod host_to_service_tests { + use super::*; + + #[test] + fn test_known_hosts() { + assert_eq!(host_to_service("github.com"), "github"); + assert_eq!(host_to_service("gitlab.com"), "gitlab"); + assert_eq!(host_to_service("bitbucket.org"), "bitbucket"); + assert_eq!(host_to_service("codeberg.org"), "codeberg"); + assert_eq!(host_to_service("gitea.com"), "gitea"); + assert_eq!(host_to_service("gitee.com"), "gitee"); + } + + #[test] + fn test_www_prefix() { + assert_eq!(host_to_service("www.github.com"), "github"); + assert_eq!(host_to_service("www.gitlab.com"), "gitlab"); + } + + #[test] + fn test_case_insensitive() { + assert_eq!(host_to_service("GitHub.Com"), "github"); + assert_eq!(host_to_service("GITLAB.COM"), "gitlab"); + assert_eq!(host_to_service("BitBucket.Org"), "bitbucket"); + } + + #[test] + fn test_trailing_slash() { + assert_eq!(host_to_service("github.com/"), "github"); + assert_eq!(host_to_service("gitlab.com//"), "gitlab"); + } + + #[test] + fn test_unknown_host_passthrough() { + assert_eq!(host_to_service("example.com"), "example.com"); + assert_eq!( + host_to_service("git.internal.corp"), + "git.internal.corp" + ); + assert_eq!(host_to_service("my-server.local"), "my-server.local"); + } + + #[test] + fn test_empty_host() { + assert_eq!(host_to_service(""), ""); + } +} + +// --------------------------------------------------------------------------- +// Unit tests for CredentialAttributes +// --------------------------------------------------------------------------- + +mod credential_attributes_tests { + use super::*; + + #[test] + fn test_parse_basic() { + let input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + assert_eq!(attrs.username.as_deref(), Some("octocat")); + assert_eq!(attrs.password.as_deref(), Some("ghp_token123")); + assert!(attrs.path.is_none()); + } + + #[test] + fn test_parse_with_path() { + let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.path.as_deref(), Some("owner/repo.git")); + } + + #[test] + fn test_parse_empty_input() { + let attrs = CredentialAttributes::parse_str(""); + assert!(attrs.protocol.is_none()); + assert!(attrs.host.is_none()); + assert!(attrs.username.is_none()); + assert!(attrs.password.is_none()); + assert!(attrs.path.is_none()); + } + + #[test] + fn test_parse_ignores_unknown_keys() { + let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + } + + #[test] + fn test_parse_stops_at_blank_line() { + let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("https")); + assert_eq!(attrs.host.as_deref(), Some("github.com")); + assert!(attrs.password.is_none()); + } + + #[test] + fn test_parse_value_containing_equals() { + let input = + "protocol=https\nhost=github.com\npassword=token=with=equals\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.password.as_deref(), Some("token=with=equals")); + } + + #[test] + fn test_parse_empty_values() { + let input = "protocol=\nhost=github.com\nusername=\n\n"; + let attrs = CredentialAttributes::parse_str(input); + assert_eq!(attrs.protocol.as_deref(), Some("")); + assert_eq!(attrs.username.as_deref(), Some("")); + } + + #[test] + fn test_serialize_roundtrip() { + let attrs = CredentialAttributes { + protocol: Some("https".to_string()), + host: Some("github.com".to_string()), + path: Some("owner/repo.git".to_string()), + username: Some("octocat".to_string()), + password: Some("ghp_token".to_string()), + }; + let serialized = attrs.serialize(); + let reparsed = CredentialAttributes::parse_str(&serialized); + assert_eq!(attrs, reparsed); + } + + #[test] + fn test_serialize_partial() { + let attrs = CredentialAttributes { + protocol: Some("https".to_string()), + host: Some("github.com".to_string()), + path: None, + username: None, + password: None, + }; + let serialized = attrs.serialize(); + assert!(serialized.contains("protocol=https\n")); + assert!(serialized.contains("host=github.com\n")); + assert!(!serialized.contains("path=")); + assert!(!serialized.contains("username=")); + assert!(!serialized.contains("password=")); + assert!(serialized.ends_with("\n\n")); + } + + #[test] + fn test_serialize_empty() { + let attrs = CredentialAttributes::default(); + let serialized = attrs.serialize(); + // Should just be the terminating blank line + assert_eq!(serialized, "\n"); + } + + #[test] + fn test_default_is_all_none() { + let attrs = CredentialAttributes::default(); + assert!(attrs.protocol.is_none()); + assert!(attrs.host.is_none()); + assert!(attrs.path.is_none()); + assert!(attrs.username.is_none()); + assert!(attrs.password.is_none()); + } + + #[test] + fn test_equality() { + let a = CredentialAttributes { + protocol: Some("https".to_string()), + host: Some("github.com".to_string()), + path: None, + username: Some("user".to_string()), + password: Some("pass".to_string()), + }; + let b = a.clone(); + assert_eq!(a, b); + + let c = CredentialAttributes { + protocol: Some("http".to_string()), + ..a.clone() + }; + assert_ne!(a, c); + } +} + +// --------------------------------------------------------------------------- +// CLI integration tests: help visibility +// --------------------------------------------------------------------------- + +mod help_visibility_tests { + use super::*; + + #[test] + fn test_credential_hidden_from_main_help() { + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("credential").not()); + } + + #[test] + fn test_credential_hidden_from_no_args_help() { + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("credential").not()); + } + + #[test] + fn test_credential_get_subcommand_hidden() { + let mut cmd = cargo_bin_cmd!("quicommit"); + cmd.args(["credential", "--help"]); + // The credential command exists but is hidden; --help should still work + // for the command itself (clap shows help for explicitly invoked hidden + // commands), but get/store/erase should be hidden from its listing. + cmd.assert().success(); + } + + #[test] + fn test_credential_command_still_executable() { + // Even though hidden, the command should be parseable and executable. + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "exec"); + + let input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", input, &config_path); + assert!(output.status.success(), "credential get should succeed"); + } +} + +// --------------------------------------------------------------------------- +// CLI integration tests: credential get (no keyring required) +// --------------------------------------------------------------------------- + +mod credential_get_tests { + use super::*; + + #[test] + fn test_get_with_no_config_outputs_nothing() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("nonexistent.toml"); + + let input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", input, &config_path); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.is_empty(), + "get with no config should produce no output, got: {}", + stdout + ); + } + + #[test] + fn test_get_with_config_but_no_profiles_outputs_nothing() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + // Create an empty config file (default config, no profiles) + ConfigManager::with_path_fresh(&config_path) + .unwrap() + .save() + .unwrap(); + + let input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", input, &config_path); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.is_empty(), + "get with no profiles should produce no output" + ); + } + + #[test] + fn test_get_with_profile_but_no_pat_outputs_nothing() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + // Use a unique profile so parallel tests storing PATs don't interfere. + write_config_with_unique_profile(&config_path, "getnpat"); + + let input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", input, &config_path); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.is_empty(), + "get with no stored PAT should produce no output, got: {}", + stdout + ); + } + + #[test] + fn test_get_without_host_outputs_nothing() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "getnohost"); + + let input = "protocol=https\n\n"; + let output = run_credential("get", input, &config_path); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.is_empty()); + } + + #[test] + fn test_get_with_empty_host_outputs_nothing() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "getempty"); + + let input = "protocol=https\nhost=\n\n"; + let output = run_credential("get", input, &config_path); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.is_empty()); + } + + #[test] + fn test_get_with_empty_stdin_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "getemptyin"); + + let output = run_credential("get", "", &config_path); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.is_empty()); + } +} + +// --------------------------------------------------------------------------- +// CLI integration tests: credential store (no keyring required for no-op cases) +// --------------------------------------------------------------------------- + +mod credential_store_tests { + use super::*; + + #[test] + fn test_store_with_no_config_succeeds_silently() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("nonexistent.toml"); + + let input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token\n\n"; + let output = run_credential("store", input, &config_path); + + assert!( + output.status.success(), + "store with no config should succeed" + ); + } + + #[test] + fn test_store_with_no_profiles_succeeds_silently() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + ConfigManager::with_path_fresh(&config_path) + .unwrap() + .save() + .unwrap(); + + let input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token\n\n"; + let output = run_credential("store", input, &config_path); + + assert!(output.status.success()); + } + + #[test] + fn test_store_without_host_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "storenohost"); + + let input = "protocol=https\nusername=octocat\npassword=ghp_token\n\n"; + let output = run_credential("store", input, &config_path); + + assert!(output.status.success()); + } + + #[test] + fn test_store_without_password_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "storenopass"); + + let input = "protocol=https\nhost=github.com\nusername=octocat\n\n"; + let output = run_credential("store", input, &config_path); + + assert!(output.status.success()); + } + + #[test] + fn test_store_with_empty_stdin_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "storeempty"); + + let output = run_credential("store", "", &config_path); + + assert!(output.status.success()); + } +} + +// --------------------------------------------------------------------------- +// CLI integration tests: credential erase (no keyring required for no-op cases) +// --------------------------------------------------------------------------- + +mod credential_erase_tests { + use super::*; + + #[test] + fn test_erase_with_no_config_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("nonexistent.toml"); + + let input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("erase", input, &config_path); + + assert!(output.status.success()); + } + + #[test] + fn test_erase_with_no_profiles_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + ConfigManager::with_path_fresh(&config_path) + .unwrap() + .save() + .unwrap(); + + let input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("erase", input, &config_path); + + assert!(output.status.success()); + } + + #[test] + fn test_erase_without_host_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "erasenohost"); + + let input = "protocol=https\n\n"; + let output = run_credential("erase", input, &config_path); + + assert!(output.status.success()); + } + + #[test] + fn test_erase_with_profile_but_no_token_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "erasenotoken"); + + let input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("erase", input, &config_path); + + assert!(output.status.success()); + } + + #[test] + fn test_erase_with_empty_stdin_succeeds() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "eraseempty"); + + let output = run_credential("erase", "", &config_path); + + assert!(output.status.success()); + } +} + +// --------------------------------------------------------------------------- +// Full store/get/erase cycle (requires keyring) +// --------------------------------------------------------------------------- + +mod credential_full_cycle_tests { + use super::*; + + #[test] + fn test_store_then_get_returns_credentials() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "cycle_sg"); + + // Clean up any leftover PAT from a previous run + cleanup_pat(&profile_name, &user_email, "github"); + + // Store a credential + let store_input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_test_token_123\n\n"; + let output = run_credential("store", store_input, &config_path); + assert!(output.status.success(), "store should succeed"); + + // Get the credential back + let get_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success(), "get should succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("protocol=https"), + "output should contain protocol, got: {}", + stdout + ); + assert!( + stdout.contains("host=github.com"), + "output should contain host, got: {}", + stdout + ); + assert!( + stdout.contains("password=ghp_test_token_123"), + "output should contain the stored password, got: {}", + stdout + ); + + // Cleanup + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_erase_after_store_removes_credentials() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "cycle_es"); + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store a credential + let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_erase_test_token\n\n"; + let output = run_credential("store", store_input, &config_path); + assert!(output.status.success()); + + // Erase it + let erase_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("erase", erase_input, &config_path); + assert!(output.status.success()); + + // Get should now return nothing + let get_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.is_empty(), + "get after erase should produce no output, got: {}", + stdout + ); + + // Cleanup just in case + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_get_returns_profile_username_as_fallback() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "cycle_un"); + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store a credential (git provides a username) + let store_input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_username_test\n\n"; + let output = run_credential("store", store_input, &config_path); + assert!(output.status.success()); + + // Get without providing a username — should fall back to profile user_name + let get_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + // Should contain either the git-supplied username or the profile's user_name + assert!( + stdout.contains("username="), + "output should contain a username, got: {}", + stdout + ); + + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_store_with_gitlab_host() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "cycle_gl"); + + cleanup_pat(&profile_name, &user_email, "gitlab"); + + // Store a credential for gitlab + let store_input = "protocol=https\nhost=gitlab.com\nusername=testuser\npassword=glpat-token\n\n"; + let output = run_credential("store", store_input, &config_path); + assert!(output.status.success()); + + // Get it back + let get_input = "protocol=https\nhost=gitlab.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("password=glpat-token"), + "output should contain gitlab token, got: {}", + stdout + ); + + cleanup_pat(&profile_name, &user_email, "gitlab"); + } + + #[test] + fn test_store_with_unknown_host() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "cycle_uh"); + + let unknown_host = "custom-git.example.com"; + cleanup_pat(&profile_name, &user_email, unknown_host); + + // Store a credential for an unknown host + let store_input = format!( + "protocol=https\nhost={}\nusername=user\npassword=custom_token\n\n", + unknown_host + ); + let output = run_credential("store", &store_input, &config_path); + assert!(output.status.success()); + + // Get it back + let get_input = format!("protocol=https\nhost={}\n\n", unknown_host); + let output = run_credential("get", &get_input, &config_path); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("password=custom_token"), + "output should contain custom token, got: {}", + stdout + ); + + cleanup_pat(&profile_name, &user_email, unknown_host); + } + + #[test] + fn test_erase_nonexistent_pat_succeeds() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + write_config_with_unique_profile(&config_path, "cycle_en"); + + // Erasing a PAT that was never stored should not fail + let erase_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("erase", erase_input, &config_path); + assert!(output.status.success()); + } +} + +// --------------------------------------------------------------------------- +// Config integration: verify store updates the profile config +// --------------------------------------------------------------------------- + +mod credential_config_integration_tests { + use super::*; + + #[test] + fn test_store_adds_token_to_profile_config() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "cfg_add"); + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store a credential + let store_input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_config_test\n\n"; + let output = run_credential("store", store_input, &config_path); + assert!(output.status.success()); + + // Read the config back and check that the token was registered + let manager = ConfigManager::with_path(&config_path).unwrap(); + let profile = manager.get_profile(&profile_name).unwrap(); + assert!( + profile.tokens.contains_key("github"), + "profile should have a 'github' token entry after store" + ); + + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_erase_removes_token_from_profile_config() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "cfg_rm"); + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store a credential + let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_erase_config\n\n"; + run_credential("store", store_input, &config_path); + + // Verify it was added + let manager = ConfigManager::with_path(&config_path).unwrap(); + assert!(manager + .get_profile(&profile_name) + .unwrap() + .tokens + .contains_key("github")); + + // Erase it + let erase_input = "protocol=https\nhost=github.com\n\n"; + run_credential("erase", erase_input, &config_path); + + // Verify it was removed from config + let manager = ConfigManager::with_path(&config_path).unwrap(); + assert!( + !manager + .get_profile(&profile_name) + .unwrap() + .tokens + .contains_key("github"), + "token entry should be removed from config after erase" + ); + + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_store_does_not_duplicate_token_entry() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + + // Manually add a token entry to the profile + let (profile_name, user_email) = { + let pname = unique_profile_name("cfg_dedup"); + let uemail = unique_email("cfg_dedup"); + let mut manager = ConfigManager::with_path_fresh(&config_path).unwrap(); + let mut profile = GitProfile::new( + pname.clone(), + "Test User".to_string(), + uemail.clone(), + ); + profile.add_token( + "github".to_string(), + TokenConfig::new(TokenType::Personal), + ); + manager.add_profile(pname.clone(), profile).unwrap(); + manager.set_default_profile(Some(pname.clone())).unwrap(); + manager.save().unwrap(); + (pname, uemail) + }; + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store a credential + let store_input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_dedup_test\n\n"; + let output = run_credential("store", store_input, &config_path); + assert!(output.status.success()); + + // Verify there's still only one token entry + let manager = ConfigManager::with_path(&config_path).unwrap(); + let profile = manager.get_profile(&profile_name).unwrap(); + assert_eq!( + profile.tokens.len(), + 1, + "should not duplicate token entry" + ); + + cleanup_pat(&profile_name, &user_email, "github"); + } +} + +// --------------------------------------------------------------------------- +// get_pat_for_host public API test +// --------------------------------------------------------------------------- + +mod get_pat_for_host_tests { + #[test] + fn test_get_pat_for_host_returns_none_without_config() { + // This test uses the default config path which may or may not exist. + // We just verify the function doesn't panic. + let result = quicommit::commands::credential::get_pat_for_host("github.com"); + assert!(result.is_ok(), "get_pat_for_host should not error"); + } +} + +// --------------------------------------------------------------------------- +// Edge cases and protocol compliance +// --------------------------------------------------------------------------- + +mod protocol_compliance_tests { + use super::*; + + #[test] + fn test_get_output_ends_with_blank_line() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "proto_blank"); + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store + let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_protocol_test\n\n"; + run_credential("store", store_input, &config_path); + + // Get + let get_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + // Git expects the output to end with a blank line + assert!( + stdout.ends_with("\n\n"), + "credential output should end with a blank line, got: {:?}", + stdout + ); + + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_get_output_has_key_value_format() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "proto_kv"); + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store + let store_input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_format_test\n\n"; + run_credential("store", store_input, &config_path); + + // Get + let get_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + // Every non-blank line should be in key=value format + for line in stdout.lines() { + if !line.is_empty() { + assert!( + line.contains('='), + "line should be in key=value format: {:?}", + line + ); + } + } + + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_get_with_password_containing_special_chars() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "proto_sc"); + + cleanup_pat(&profile_name, &user_email, "github"); + + // Store a password with special characters + let special_token = "ghp_t0k3n!@#$%^&*()_+-=[]{}|;:,.<>?"; + let store_input = format!( + "protocol=https\nhost=github.com\nusername=octocat\npassword={}\n\n", + special_token + ); + let output = run_credential("store", &store_input, &config_path); + assert!(output.status.success()); + + // Get it back + let get_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains(&format!("password={}", special_token)), + "output should contain the special-char password, got: {}", + stdout + ); + + cleanup_pat(&profile_name, &user_email, "github"); + } + + #[test] + fn test_multiple_hosts_use_different_services() { + if !keyring_available() { + eprintln!("[skip] keyring not available"); + return; + } + + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("config.toml"); + let (profile_name, user_email) = + write_config_with_unique_profile(&config_path, "proto_mh"); + + cleanup_pat(&profile_name, &user_email, "github"); + cleanup_pat(&profile_name, &user_email, "gitlab"); + + // Store GitHub credential + let store_input = + "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_multi_test\n\n"; + run_credential("store", store_input, &config_path); + + // Store GitLab credential + let store_input = "protocol=https\nhost=gitlab.com\nusername=testuser\npassword=glpat-multi\n\n"; + run_credential("store", store_input, &config_path); + + // Get GitHub — should return GitHub token, not GitLab + let get_input = "protocol=https\nhost=github.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("password=ghp_multi_test"), + "should contain github token, got: {}", + stdout + ); + assert!(!stdout.contains("glpat-multi")); + + // Get GitLab — should return GitLab token, not GitHub + let get_input = "protocol=https\nhost=gitlab.com\n\n"; + let output = run_credential("get", get_input, &config_path); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("password=glpat-multi"), + "should contain gitlab token, got: {}", + stdout + ); + assert!(!stdout.contains("ghp_multi_test")); + + // Cleanup + cleanup_pat(&profile_name, &user_email, "github"); + cleanup_pat(&profile_name, &user_email, "gitlab"); + } +}