1use anyhow::{Context, Result};
6use std::path::Path;
7
8use crate::providers;
9use crate::providers::ProviderKind;
10use crate::snippet;
11use crate::ssh_config::model::{HostEntry, SshConfigFile};
12use crate::vault_ssh;
13
14use super::cli_args::{
15 PasswordCommands, ProviderCommands, SnippetCommands, ThemeCommands, TunnelCommands,
16};
17use super::{askpass, import, logging, preferences, quick_add, should_write_certificate_file, ui};
18
19pub fn handle_quick_add(
20 mut config: SshConfigFile,
21 target: &str,
22 alias: Option<&str>,
23 key: Option<&str>,
24) -> Result<()> {
25 log::info!(
26 "[purple] cli add: target={} alias={:?} key={:?}",
27 target,
28 alias,
29 key
30 );
31 let parsed = quick_add::parse_target(target).map_err(|e| anyhow::anyhow!(e))?;
32
33 let alias_str = alias.map(|a| a.to_string()).unwrap_or_else(|| {
34 parsed
35 .hostname
36 .split('.')
37 .next()
38 .unwrap_or(&parsed.hostname)
39 .to_string()
40 });
41
42 if alias_str.trim().is_empty() {
43 eprintln!("{}", crate::messages::cli::ALIAS_EMPTY);
44 std::process::exit(1);
45 }
46 if alias_str.contains(char::is_whitespace) {
47 eprintln!("{}", crate::messages::cli::ALIAS_WHITESPACE);
48 std::process::exit(1);
49 }
50 if crate::ssh_config::model::is_host_pattern(&alias_str) {
51 eprintln!("{}", crate::messages::cli::ALIAS_PATTERN_CHARS);
52 std::process::exit(1);
53 }
54
55 let key_val = key.unwrap_or("").to_string();
57 for (value, name) in [
58 (&alias_str, "Alias"),
59 (&parsed.hostname, "Hostname"),
60 (&parsed.user, "User"),
61 (&key_val, "Identity file"),
62 ] {
63 if value.chars().any(|c| c.is_control()) {
64 eprintln!("{}", crate::messages::cli::control_chars(name));
65 std::process::exit(1);
66 }
67 }
68
69 if parsed.hostname.contains(char::is_whitespace) {
71 eprintln!("{}", crate::messages::cli::HOSTNAME_WHITESPACE);
72 std::process::exit(1);
73 }
74 if parsed.user.contains(char::is_whitespace) {
75 eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
76 std::process::exit(1);
77 }
78
79 if config.has_host(&alias_str) {
80 eprintln!("{}", crate::messages::cli::alias_already_exists(&alias_str));
81 std::process::exit(1);
82 }
83
84 let entry = HostEntry {
85 alias: alias_str.clone(),
86 hostname: parsed.hostname,
87 user: parsed.user,
88 port: parsed.port,
89 identity_file: key_val,
90 ..Default::default()
91 };
92
93 config.add_host(&entry);
94 log::debug!("[config] cli add: writing ssh config (alias={})", alias_str);
95 config.write()?;
96 log::info!("[purple] cli add: host added alias={}", alias_str);
97 println!("{}", crate::messages::cli::welcome(&alias_str));
98 Ok(())
99}
100
101pub fn handle_import(
102 mut config: SshConfigFile,
103 file: Option<&str>,
104 known_hosts: bool,
105 group: Option<&str>,
106) -> Result<()> {
107 log::info!(
108 "[purple] cli import: source={} group={:?}",
109 if known_hosts {
110 "known_hosts".to_string()
111 } else {
112 file.unwrap_or("(missing)").to_string()
113 },
114 group
115 );
116 let result = if known_hosts {
117 import::import_from_known_hosts(&mut config, group)
118 } else if let Some(path) = file {
119 let resolved = super::resolve_config_path(path)?;
120 import::import_from_file(&mut config, &resolved, group)
121 } else {
122 eprintln!("{}", crate::messages::cli::IMPORT_NO_FILE);
123 std::process::exit(1);
124 };
125
126 match result {
127 Ok((imported, skipped, parse_failures, read_errors)) => {
128 if imported > 0 {
129 log::debug!(
130 "[config] cli import: writing ssh config ({} new hosts)",
131 imported
132 );
133 config.write()?;
134 }
135 log::info!(
136 "[purple] cli import: imported={} skipped={} parse_failures={} read_errors={}",
137 imported,
138 skipped,
139 parse_failures,
140 read_errors
141 );
142 println!("{}", crate::messages::imported_hosts(imported, skipped));
143 if parse_failures > 0 {
144 eprintln!(
145 "{}",
146 crate::messages::cli::import_parse_failures(parse_failures)
147 );
148 }
149 if read_errors > 0 {
150 eprintln!("{}", crate::messages::cli::import_read_errors(read_errors));
151 }
152 Ok(())
153 }
154 Err(e) => {
155 eprintln!("{}", e);
156 std::process::exit(1);
157 }
158 }
159}
160
161pub fn handle_sync(
162 mut config: SshConfigFile,
163 provider_name: Option<&str>,
164 dry_run: bool,
165 remove: bool,
166) -> Result<()> {
167 log::info!(
168 "[purple] cli sync: provider={:?} dry_run={} remove={}",
169 provider_name,
170 dry_run,
171 remove
172 );
173 let provider_config = providers::config::ProviderConfig::load();
174 let sections: Vec<&providers::config::ProviderSection> = if let Some(arg) = provider_name {
178 let id: providers::config::ProviderConfigId = match arg.parse() {
179 Ok(id) => id,
180 Err(e) => {
181 eprintln!("{}: {}", arg, e);
182 std::process::exit(1);
183 }
184 };
185 if providers::get_provider(&id.provider).is_none() {
186 eprintln!("{}", crate::messages::cli::unknown_provider(&id.provider));
187 std::process::exit(1);
188 }
189 let matched: Vec<&providers::config::ProviderSection> = match &id.label {
190 Some(_) => provider_config.section_by_id(&id).into_iter().collect(),
191 None => provider_config.sections_for_provider(&id.provider),
192 };
193 if matched.is_empty() {
194 eprintln!("{}", crate::messages::cli::no_config_for(arg));
195 std::process::exit(1);
196 }
197 matched
198 } else {
199 let configured = provider_config.configured_providers();
200 if configured.is_empty() {
201 eprintln!("{}", crate::messages::cli::NO_PROVIDERS);
202 std::process::exit(1);
203 }
204 configured.iter().collect()
205 };
206
207 let mut any_changes = false;
208 let mut any_failures = false;
209 let mut any_hard_failures = false;
210 let mut all_renames: Vec<(String, String)> = Vec::new();
211
212 for section in §ions {
213 let provider = match providers::get_provider_with_config(section) {
214 Some(p) => p,
215 None => {
216 log::warn!(
217 "[config] cli sync: skipping unknown provider '{}'",
218 section.provider()
219 );
220 eprintln!(
221 "{}",
222 crate::messages::cli::skipping_unknown_provider(section.provider())
223 );
224 any_failures = true;
225 continue;
228 }
229 };
230 let display_name = providers::provider_display_name(section.provider());
231 log::debug!(
232 "[external] cli sync: starting provider={} label={:?}",
233 section.provider(),
234 section.id.label
235 );
236 let is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
237 print!("{}", crate::messages::cli::syncing_start(display_name));
238 let _ = std::io::Write::flush(&mut std::io::stdout());
239
240 let last_summary = std::cell::RefCell::new(String::new());
241 let progress = |msg: &str| {
242 *last_summary.borrow_mut() = msg.to_string();
243 if is_tty {
244 print!("{}", crate::messages::cli::syncing(display_name, msg));
245 let _ = std::io::Write::flush(&mut std::io::stdout());
246 }
247 };
248 let fetch_result = provider.fetch_hosts_with_progress(
249 §ion.token,
250 &std::sync::atomic::AtomicBool::new(false),
251 &progress,
252 );
253 let summary = last_summary.into_inner();
254 if is_tty {
256 if summary.is_empty() {
257 print!("{}", crate::messages::cli::syncing(display_name, ""));
258 } else {
259 println!("{}", crate::messages::cli::syncing(display_name, &summary));
260 }
261 let _ = std::io::Write::flush(&mut std::io::stdout());
262 } else if !summary.is_empty() {
263 println!("{}", summary);
264 }
265 let (hosts, suppress_remove) = match fetch_result {
266 Ok(hosts) => (hosts, false),
267 Err(providers::ProviderError::PartialResult {
268 hosts,
269 failures,
270 total,
271 }) => {
272 println!(
273 "{}",
274 crate::messages::cli::servers_found_with_failures(hosts.len(), failures, total)
275 );
276 if remove {
277 eprintln!("{}", crate::messages::cli::sync_skip_remove(display_name));
278 }
279 any_failures = true;
280 (hosts, true)
281 }
282 Err(e) => {
283 println!("{}", crate::messages::cli::SYNC_FAILED);
284 eprintln!("{}", crate::messages::cli::sync_error(display_name, &e));
285 any_failures = true;
286 any_hard_failures = true;
287 continue;
288 }
289 };
290 if !suppress_remove {
291 println!("{}", crate::messages::cli::servers_found(hosts.len()));
292 }
293 let effective_remove = remove && !suppress_remove;
294 let result = providers::sync::sync_provider(
295 &mut config,
296 &*provider,
297 &hosts,
298 section,
299 effective_remove,
300 suppress_remove, dry_run,
302 );
303 let prefix = if dry_run {
304 crate::messages::cli::SYNC_RESULT_PREFIX_DRY_RUN
305 } else {
306 crate::messages::cli::SYNC_RESULT_PREFIX_LIVE
307 };
308 println!(
309 "{}",
310 crate::messages::cli::sync_result(
311 prefix,
312 result.added,
313 result.updated,
314 result.unchanged
315 )
316 );
317 if result.removed > 0 {
318 println!("{}", crate::messages::cli::sync_removed(result.removed));
319 }
320 if result.stale > 0 {
321 println!("{}", crate::messages::cli::sync_stale(result.stale));
322 }
323 if result.added > 0 || result.updated > 0 || result.removed > 0 || result.stale > 0 {
324 any_changes = true;
325 }
326 if !dry_run {
327 all_renames.extend(result.renames);
328 }
329 }
330
331 if any_changes && !dry_run {
332 if any_hard_failures {
333 log::warn!("[config] cli sync: skipping ssh config write due to hard failures");
334 eprintln!("{}", crate::messages::cli::SYNC_SKIP_WRITE);
335 } else {
336 log::debug!("[config] cli sync: writing ssh config");
337 config.write()?;
338 log::info!("[purple] cli sync: ssh config written");
339 if !all_renames.is_empty() {
344 log::info!(
345 "[purple] cli sync: migrating per-host state for {} rename(s)",
346 all_renames.len()
347 );
348 crate::app::migrate_renames_persistent_state(&all_renames);
349 }
350 }
351 }
352
353 if any_failures {
354 log::warn!("[purple] cli sync: completed with failures (exit 1)");
355 std::process::exit(1);
356 }
357
358 log::info!("[purple] cli sync: completed successfully");
359 Ok(())
360}
361
362pub fn handle_provider_command(command: ProviderCommands) -> Result<()> {
363 log::info!("[purple] cli provider: dispatch");
364 match command {
365 ProviderCommands::Add {
366 provider,
367 token,
368 token_stdin,
369 mut prefix,
370 mut user,
371 mut key,
372 url,
373 mut profile,
374 mut regions,
375 mut project,
376 mut compartment,
377 no_verify_tls,
378 verify_tls,
379 auto_sync,
380 no_auto_sync,
381 label,
382 } => {
383 let p = match providers::get_provider(&provider) {
384 Some(p) => p,
385 None => {
386 eprintln!(
387 "Never heard of '{}'. Try: digitalocean, vultr, linode, hetzner, upcloud, proxmox, aws, scaleway, gcp, azure, tailscale, oracle, ovh, leaseweb, i3d, transip.",
388 provider
389 );
390 std::process::exit(1);
391 }
392 };
393 let kind = provider.parse::<ProviderKind>().ok();
395
396 let mut token = token;
398 let mut url = url;
399 let mut no_verify_tls = no_verify_tls;
400 let mut verify_tls = verify_tls;
401 if kind != Some(ProviderKind::Proxmox) {
402 if url.is_some() {
403 eprintln!("{}", crate::messages::cli::WARN_URL_NOT_USED);
404 url = None;
405 }
406 if no_verify_tls {
407 eprintln!("{}", crate::messages::cli::WARN_NO_VERIFY_TLS_NOT_USED);
408 no_verify_tls = false;
409 }
410 if verify_tls {
411 eprintln!("{}", crate::messages::cli::WARN_VERIFY_TLS_NOT_USED);
412 verify_tls = false;
413 }
414 }
415 if kind != Some(ProviderKind::Aws) && profile.is_some() {
417 eprintln!("{}", crate::messages::cli::WARN_PROFILE_NOT_USED);
418 profile = None;
419 }
420 if !kind.is_some_and(ProviderKind::accepts_cli_regions) && regions.is_some() {
421 eprintln!("{}", crate::messages::cli::WARN_REGIONS_NOT_USED);
422 regions = None;
423 }
424 if kind != Some(ProviderKind::Gcp) && project.is_some() {
425 eprintln!("{}", crate::messages::cli::WARN_PROJECT_NOT_USED);
426 project = None;
427 }
428 if kind != Some(ProviderKind::Oracle) && compartment.is_some() {
429 eprintln!("{}", crate::messages::cli::WARN_COMPARTMENT_NOT_USED);
430 compartment = None;
431 }
432
433 let existing_section = providers::config::ProviderConfig::load()
435 .section(&provider)
436 .cloned();
437
438 if let Some(ref existing) = existing_section {
439 if kind == Some(ProviderKind::Proxmox) && url.is_none() && !existing.url.is_empty()
441 {
442 url = Some(existing.url.clone());
443 }
444 if token.is_none()
445 && !token_stdin
446 && std::env::var("PURPLE_TOKEN").is_err()
447 && !existing.token.is_empty()
448 {
449 token = Some(existing.token.clone());
450 }
451 if prefix.is_none() {
452 prefix = Some(existing.alias_prefix.clone());
453 }
454 if user.is_none() {
455 user = Some(existing.user.clone());
456 }
457 if key.is_none() && !existing.identity_file.is_empty() {
458 key = Some(existing.identity_file.clone());
459 }
460 if !no_verify_tls && !verify_tls && !existing.verify_tls {
462 no_verify_tls = true;
463 }
464 if kind == Some(ProviderKind::Aws)
466 && profile.is_none()
467 && !existing.profile.is_empty()
468 {
469 profile = Some(existing.profile.clone());
470 }
471 if kind.is_some_and(ProviderKind::accepts_cli_regions)
473 && regions.is_none()
474 && !existing.regions.is_empty()
475 {
476 regions = Some(existing.regions.clone());
477 }
478 if kind == Some(ProviderKind::Gcp)
480 && project.is_none()
481 && !existing.project.is_empty()
482 {
483 project = Some(existing.project.clone());
484 }
485 if kind == Some(ProviderKind::Oracle)
487 && compartment.is_none()
488 && !existing.compartment.is_empty()
489 {
490 compartment = Some(existing.compartment.clone());
491 }
492 }
493
494 if kind == Some(ProviderKind::Proxmox) {
496 if url.is_none() || url.as_deref().unwrap_or("").trim().is_empty() {
497 eprintln!("{}", crate::messages::cli::PROXMOX_URL_REQUIRED);
498 std::process::exit(1);
499 }
500 let u = url.as_deref().unwrap();
501 if !u.to_ascii_lowercase().starts_with("https://") {
502 eprintln!("{}", crate::messages::cli::PROVIDER_URL_REQUIRES_HTTPS);
503 std::process::exit(1);
504 }
505 }
506
507 let aws_has_profile = kind == Some(ProviderKind::Aws)
509 && profile.as_deref().is_some_and(|p| !p.trim().is_empty());
510 let token = if aws_has_profile
511 && token.is_none()
512 && !token_stdin
513 && std::env::var("PURPLE_TOKEN").is_err()
514 {
515 String::new()
516 } else {
517 match super::resolve_token(token, token_stdin) {
518 Ok(t) => t,
519 Err(e) => {
520 eprintln!("{}", e);
521 std::process::exit(1);
522 }
523 }
524 };
525
526 if token.trim().is_empty() && !aws_has_profile && kind != Some(ProviderKind::Tailscale)
527 {
528 if kind == Some(ProviderKind::Gcp) {
529 eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_GCP);
530 } else if kind == Some(ProviderKind::Oracle) {
531 eprintln!("{}", crate::messages::cli::PROVIDER_TOKEN_REQUIRED_ORACLE);
532 } else {
533 eprintln!(
534 "{}",
535 crate::messages::cli::provider_token_required(
536 providers::provider_display_name(&provider)
537 )
538 );
539 }
540 std::process::exit(1);
541 }
542
543 let alias_prefix = prefix.unwrap_or_else(|| p.short_label().to_string());
544 if crate::ssh_config::model::is_host_pattern(&alias_prefix) {
545 eprintln!("{}", crate::messages::cli::ALIAS_PREFIX_INVALID);
546 std::process::exit(1);
547 }
548
549 let user = user.unwrap_or_else(|| "root".to_string());
550 let identity_file = key.unwrap_or_default();
551
552 let url_value = url.clone().unwrap_or_default();
554 let profile_value = profile.clone().unwrap_or_default();
555 let regions_value = regions.clone().unwrap_or_default();
556 let project_value = project.clone().unwrap_or_default();
557 let compartment_value = compartment.clone().unwrap_or_default();
558 for (value, name) in [
559 (&url_value, "URL"),
560 (&token, "Token"),
561 (&alias_prefix, "Alias prefix"),
562 (&user, "User"),
563 (&identity_file, "Identity file"),
564 (&profile_value, "Profile"),
565 (&project_value, "Project"),
566 (®ions_value, "Regions"),
567 (&compartment_value, "Compartment"),
568 ] {
569 if value.chars().any(|c| c.is_control()) {
570 eprintln!("{}", crate::messages::cli::control_chars(name));
571 std::process::exit(1);
572 }
573 }
574 if user.contains(char::is_whitespace) {
575 eprintln!("{}", crate::messages::cli::USER_WHITESPACE);
576 std::process::exit(1);
577 }
578
579 let resolved_auto_sync = if auto_sync {
581 true
582 } else if no_auto_sync {
583 false
584 } else if let Some(ref existing) = existing_section {
585 existing.auto_sync
586 } else {
587 kind != Some(ProviderKind::Proxmox)
588 };
589
590 let resolved_profile = profile.unwrap_or_default();
591 let resolved_regions = regions.unwrap_or_default();
592 let resolved_project = project.unwrap_or_default();
593 let resolved_compartment = compartment.unwrap_or_default();
594
595 if kind == Some(ProviderKind::Aws) && resolved_regions.trim().is_empty() {
597 eprintln!("{}", crate::messages::cli::AWS_REGIONS_REQUIRED);
598 std::process::exit(1);
599 }
600 if kind == Some(ProviderKind::Scaleway) && resolved_regions.trim().is_empty() {
601 eprintln!("{}", crate::messages::cli::SCALEWAY_REGIONS_REQUIRED);
602 std::process::exit(1);
603 }
604 if kind == Some(ProviderKind::Azure) {
605 if resolved_regions.trim().is_empty() {
606 eprintln!("{}", crate::messages::cli::AZURE_REGIONS_REQUIRED);
607 std::process::exit(1);
608 }
609 for sub in resolved_regions
610 .split(',')
611 .map(|s| s.trim())
612 .filter(|s| !s.is_empty())
613 {
614 if !providers::azure::is_valid_subscription_id(sub) {
615 eprintln!(
616 "{}",
617 crate::messages::cli::azure_subscription_id_invalid(sub)
618 );
619 std::process::exit(1);
620 }
621 }
622 }
623 if kind == Some(ProviderKind::Gcp) && resolved_project.trim().is_empty() {
625 eprintln!("{}", crate::messages::cli::GCP_PROJECT_REQUIRED);
626 std::process::exit(1);
627 }
628 if kind == Some(ProviderKind::Oracle) && resolved_compartment.trim().is_empty() {
630 eprintln!("{}", crate::messages::cli::ORACLE_COMPARTMENT_REQUIRED);
631 std::process::exit(1);
632 }
633
634 let mut config = providers::config::ProviderConfig::load();
635
636 let id: providers::config::ProviderConfigId = match label.as_deref() {
642 Some(l) => {
643 if let Err(e) = providers::config::validate_label(l) {
644 eprintln!("{}", crate::messages::cli::invalid_label_flag(&e));
645 std::process::exit(1);
646 }
647 providers::config::ProviderConfigId::labeled(provider.clone(), l)
648 }
649 None => providers::config::ProviderConfigId::bare(provider.clone()),
650 };
651
652 let existing = config.sections_for_provider(&provider);
655 let has_bare = existing.iter().any(|s| s.id.label.is_none());
656 let has_labeled = existing.iter().any(|s| s.id.label.is_some());
657 if id.label.is_none() && has_labeled {
658 eprintln!("{}", crate::messages::cli::add_requires_label(&provider));
659 std::process::exit(1);
660 }
661 if id.label.is_some() && has_bare {
662 eprintln!(
663 "{}",
664 crate::messages::cli::add_label_collides_with_bare(&provider)
665 );
666 std::process::exit(1);
667 }
668
669 let section = providers::config::ProviderSection {
670 id: id.clone(),
671 token,
672 alias_prefix,
673 user,
674 identity_file,
675 url: url.unwrap_or_default(),
676 verify_tls: !no_verify_tls,
677 auto_sync: resolved_auto_sync,
678 profile: resolved_profile,
679 regions: resolved_regions,
680 project: resolved_project,
681 compartment: resolved_compartment,
682 vault_role: String::new(),
683 vault_addr: String::new(),
684 };
685
686 config.set_section(section);
687 config
688 .save()
689 .map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
690 println!("{}", crate::messages::cli::saved_config(&id.to_string()));
691 Ok(())
692 }
693 ProviderCommands::List => {
694 let config = providers::config::ProviderConfig::load();
695 let sections = config.configured_providers();
696 if sections.is_empty() {
697 println!("{}", crate::messages::cli::NO_PROVIDERS);
698 } else {
699 for s in sections {
700 let display_name = providers::provider_display_name(s.provider());
701 let label_suffix = match &s.id.label {
702 Some(l) => format!(" ({})", l),
703 None => String::new(),
704 };
705 println!(
706 " {:<24} {}-*{:>8}",
707 format!("{}{}", display_name, label_suffix),
708 s.alias_prefix,
709 s.user
710 );
711 }
712 }
713 Ok(())
714 }
715 ProviderCommands::Remove { provider } => {
716 let id: providers::config::ProviderConfigId = match provider.parse() {
719 Ok(id) => id,
720 Err(e) => {
721 eprintln!("{}: {}", provider, e);
722 std::process::exit(1);
723 }
724 };
725 let mut config = providers::config::ProviderConfig::load();
726 let removed = match &id.label {
727 Some(_) => {
728 if config.section_by_id(&id).is_none() {
729 eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
730 std::process::exit(1);
731 }
732 config.remove_section_by_id(&id);
733 1
734 }
735 None => {
736 let count = config.sections_for_provider(&id.provider).len();
737 if count == 0 {
738 eprintln!("{}", crate::messages::cli::no_config_to_remove(&provider));
739 std::process::exit(1);
740 }
741 config.remove_section(&id.provider);
742 count
743 }
744 };
745 config
746 .save()
747 .map_err(|e| anyhow::anyhow!("Failed to save: {}", e))?;
748 if removed == 1 {
749 println!("{}", crate::messages::cli::removed_config(&provider));
750 } else {
751 println!(
752 "{}",
753 crate::messages::cli::removed_configs(&provider, removed)
754 );
755 }
756 Ok(())
757 }
758 }
759}
760
761pub fn handle_tunnel_command(mut config: SshConfigFile, command: TunnelCommands) -> Result<()> {
762 log::info!("[purple] cli tunnel: dispatch");
763 match command {
764 TunnelCommands::List { alias } => {
765 if let Some(alias) = alias {
766 if !config.has_host(&alias) {
768 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
769 std::process::exit(1);
770 }
771 let rules = config.find_tunnel_directives(&alias);
772 if rules.is_empty() {
773 println!("{}", crate::messages::cli::no_tunnels_for(&alias));
774 } else {
775 println!("{}", crate::messages::cli::tunnels_for(&alias));
776 for rule in &rules {
777 println!(" {}", rule.display());
778 }
779 }
780 } else {
781 let entries = config.host_entries();
783 let with_tunnels: Vec<_> = entries.iter().filter(|e| e.tunnel_count > 0).collect();
784 if with_tunnels.is_empty() {
785 println!("{}", crate::messages::cli::NO_TUNNELS);
786 } else {
787 for (i, host) in with_tunnels.iter().enumerate() {
788 if i > 0 {
789 println!();
790 }
791 println!("{}:", host.alias);
792 for rule in config.find_tunnel_directives(&host.alias) {
793 println!(" {}", rule.display());
794 }
795 }
796 }
797 }
798 Ok(())
799 }
800 TunnelCommands::Add { alias, forward } => {
801 if !config.has_host(&alias) {
802 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
803 std::process::exit(1);
804 }
805 if config.is_included_host(&alias) {
806 eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
807 std::process::exit(1);
808 }
809 let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
810 eprintln!("{}", e);
811 std::process::exit(1);
812 });
813 let key = rule.tunnel_type.directive_key();
814 let value = rule.to_directive_value();
815 if config.has_forward(&alias, key, &value) {
817 eprintln!("{}", crate::messages::cli::forward_exists(&forward, &alias));
818 std::process::exit(1);
819 }
820 config.add_forward(&alias, key, &value);
821 log::debug!(
822 "[config] cli tunnel add: writing ssh config (alias={})",
823 alias
824 );
825 if let Err(e) = config.write() {
826 log::warn!("[config] cli tunnel add: write failed: {}", e);
827 eprintln!("{}", crate::messages::cli::save_config_failed(&e));
828 std::process::exit(1);
829 }
830 log::info!(
831 "[purple] cli tunnel add: forward={} alias={}",
832 forward,
833 alias
834 );
835 println!("{}", crate::messages::cli::added_forward(&forward, &alias));
836 Ok(())
837 }
838 TunnelCommands::Remove { alias, forward } => {
839 if !config.has_host(&alias) {
840 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
841 std::process::exit(1);
842 }
843 if config.is_included_host(&alias) {
844 eprintln!("{}", crate::messages::cli::included_host_read_only(&alias));
845 std::process::exit(1);
846 }
847 let rule = crate::tunnel::TunnelRule::from_cli_spec(&forward).unwrap_or_else(|e| {
848 eprintln!("{}", e);
849 std::process::exit(1);
850 });
851 let key = rule.tunnel_type.directive_key();
852 let value = rule.to_directive_value();
853 let removed = config.remove_forward(&alias, key, &value);
854 if !removed {
855 eprintln!(
856 "{}",
857 crate::messages::cli::forward_not_found(&forward, &alias)
858 );
859 std::process::exit(1);
860 }
861 log::debug!(
862 "[config] cli tunnel remove: writing ssh config (alias={})",
863 alias
864 );
865 if let Err(e) = config.write() {
866 log::warn!("[config] cli tunnel remove: write failed: {}", e);
867 eprintln!("{}", crate::messages::cli::save_config_failed(&e));
868 std::process::exit(1);
869 }
870 log::info!(
871 "[purple] cli tunnel remove: forward={} alias={}",
872 forward,
873 alias
874 );
875 println!(
876 "{}",
877 crate::messages::cli::removed_forward(&forward, &alias)
878 );
879 Ok(())
880 }
881 TunnelCommands::Start { alias } => {
882 log::info!("[purple] cli tunnel start: alias={}", alias);
883 if !config.has_host(&alias) {
884 eprintln!("{}", crate::messages::cli::host_not_found(&alias));
885 std::process::exit(1);
886 }
887 let tunnels = config.find_tunnel_directives(&alias);
888 if tunnels.is_empty() {
889 log::warn!("[purple] cli tunnel start: no forwards for alias={}", alias);
890 eprintln!("{}", crate::messages::cli::no_forwards(&alias));
891 std::process::exit(1);
892 }
893 println!("{}", crate::messages::cli::starting_tunnel(&alias));
894 let status = std::process::Command::new("ssh")
896 .arg("-F")
897 .arg(&config.path)
898 .arg("-N")
899 .arg("--")
900 .arg(&alias)
901 .status()
902 .map_err(|e| anyhow::anyhow!("Failed to start ssh: {}", e))?;
903 let code = status.code().unwrap_or(1);
904 std::process::exit(code);
905 }
906 }
907}
908
909pub fn prompt_hidden_input(prompt: &str) -> Result<Option<String>> {
911 eprint!("{}", prompt);
912 crossterm::terminal::enable_raw_mode()?;
913 let mut input = String::new();
914 loop {
915 if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
916 match key.code {
917 crossterm::event::KeyCode::Enter => break,
918 crossterm::event::KeyCode::Char(c) => {
919 input.push(c);
920 eprint!("*");
921 }
922 crossterm::event::KeyCode::Backspace if input.pop().is_some() => {
923 eprint!("\x08 \x08");
924 }
925 crossterm::event::KeyCode::Esc => {
926 crossterm::terminal::disable_raw_mode()?;
927 eprintln!();
928 return Ok(None);
929 }
930 _ => {}
931 }
932 }
933 }
934 crossterm::terminal::disable_raw_mode()?;
935 eprintln!();
936 Ok(Some(input))
937}
938
939pub fn handle_password_command(command: PasswordCommands) -> Result<()> {
945 log::info!("[purple] cli password: dispatch");
946 match command {
947 PasswordCommands::Set { alias } => {
948 let password =
949 match prompt_hidden_input(&crate::messages::askpass::password_prompt(&alias))? {
950 Some(p) if !p.is_empty() => p,
951 Some(_) => {
952 eprintln!("{}", crate::messages::cli::PASSWORD_EMPTY);
953 std::process::exit(1);
954 }
955 None => {
956 eprintln!("{}", crate::messages::cli::CANCELLED);
957 std::process::exit(1);
958 }
959 };
960
961 askpass::store_in_keychain(&alias, &password)?;
962 println!(
963 "Password stored for {}. Set 'keychain' as password source to use it.",
964 alias
965 );
966 Ok(())
967 }
968 PasswordCommands::Remove { alias } => {
969 askpass::remove_from_keychain(&alias)?;
970 println!("{}", crate::messages::cli::password_removed(&alias));
971 Ok(())
972 }
973 }
974}
975
976pub fn handle_snippet_command(
977 config: SshConfigFile,
978 command: SnippetCommands,
979 config_path: &Path,
980) -> Result<()> {
981 log::info!("[purple] cli snippet: dispatch");
982 match command {
983 SnippetCommands::List => {
984 let store = snippet::SnippetStore::load();
985 if store.snippets.is_empty() {
986 println!("{}", crate::messages::cli::NO_SNIPPETS);
987 } else {
988 for s in &store.snippets {
989 if s.description.is_empty() {
990 println!(" {} {}", s.name, s.command);
991 } else {
992 println!(" {} {} ({})", s.name, s.command, s.description);
993 }
994 }
995 }
996 Ok(())
997 }
998 SnippetCommands::Add {
999 name,
1000 command,
1001 description,
1002 } => {
1003 if let Err(e) = snippet::validate_name(&name) {
1004 eprintln!("{}", e);
1005 std::process::exit(1);
1006 }
1007 if let Err(e) = snippet::validate_command(&command) {
1008 eprintln!("{}", e);
1009 std::process::exit(1);
1010 }
1011 if let Some(ref desc) = description {
1012 if desc.contains(|c: char| c.is_control()) {
1013 eprintln!("{}", crate::messages::cli::DESCRIPTION_CONTROL_CHARS);
1014 std::process::exit(1);
1015 }
1016 }
1017 let mut store = snippet::SnippetStore::load();
1018 let is_update = store.get(&name).is_some();
1019 store.set(snippet::Snippet {
1020 name: name.clone(),
1021 command,
1022 description: description.unwrap_or_default(),
1023 });
1024 store.save()?;
1025 if is_update {
1026 println!("{}", crate::messages::cli::snippet_updated(&name));
1027 } else {
1028 println!("{}", crate::messages::cli::snippet_added(&name));
1029 }
1030 Ok(())
1031 }
1032 SnippetCommands::Remove { name } => {
1033 let mut store = snippet::SnippetStore::load();
1034 if store.get(&name).is_none() {
1035 eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1036 std::process::exit(1);
1037 }
1038 store.remove(&name);
1039 store.save()?;
1040 println!("{}", crate::messages::cli::snippet_removed(&name));
1041 Ok(())
1042 }
1043 SnippetCommands::Run {
1044 name,
1045 alias,
1046 tag,
1047 all,
1048 parallel,
1049 } => {
1050 let store = snippet::SnippetStore::load();
1051 let snip = match store.get(&name) {
1052 Some(s) => s.clone(),
1053 None => {
1054 eprintln!("{}", crate::messages::cli::snippet_not_found(&name));
1055 std::process::exit(1);
1056 }
1057 };
1058
1059 let entries = config.host_entries();
1060
1061 let targets: Vec<&HostEntry> = if let Some(ref alias) = alias {
1063 match entries.iter().find(|h| h.alias == *alias) {
1064 Some(h) => vec![h],
1065 None => {
1066 eprintln!("{}", crate::messages::cli::host_not_found(alias));
1067 std::process::exit(1);
1068 }
1069 }
1070 } else if let Some(ref tag_filter) = tag {
1071 let matched: Vec<_> = entries
1072 .iter()
1073 .filter(|h| h.tags.iter().any(|t| t.eq_ignore_ascii_case(tag_filter)))
1074 .collect();
1075 if matched.is_empty() {
1076 eprintln!("{}", crate::messages::cli::no_hosts_with_tag(tag_filter));
1077 std::process::exit(1);
1078 }
1079 matched
1080 } else if all {
1081 entries.iter().collect()
1082 } else {
1083 eprintln!("{}", crate::messages::cli::SPECIFY_TARGET);
1084 std::process::exit(1);
1085 };
1086
1087 if targets.len() == 1 {
1088 let host = targets[0];
1090 let askpass = host
1091 .askpass
1092 .clone()
1093 .or_else(preferences::load_askpass_default);
1094 super::ensure_proton_login(askpass.as_deref());
1095 let bw_session = super::ensure_bw_session(None, askpass.as_deref());
1096 super::ensure_keychain_password(&host.alias, askpass.as_deref());
1097 match snippet::run_snippet(
1098 &host.alias,
1099 config_path,
1100 &snip.command,
1101 askpass.as_deref(),
1102 bw_session.as_deref(),
1103 false,
1104 false,
1105 ) {
1106 Ok(r) => {
1107 if !r.status.success() {
1108 std::process::exit(r.status.code().unwrap_or(1));
1109 }
1110 }
1111 Err(e) => {
1112 eprintln!("{}", crate::messages::cli::operation_failed(&e));
1113 std::process::exit(1);
1114 }
1115 }
1116 } else if parallel {
1117 use std::sync::mpsc;
1119 use std::thread;
1120 let (tx, rx) = mpsc::channel();
1121 let max_concurrent: usize = 20;
1122 let (slot_tx, slot_rx) = mpsc::channel();
1123 for _ in 0..max_concurrent {
1124 let _ = slot_tx.send(());
1125 }
1126 let config_path = config_path.to_path_buf();
1127 let any_bw = targets.iter().any(|h| {
1129 let askpass = h.askpass.clone().or_else(preferences::load_askpass_default);
1130 askpass.as_deref().unwrap_or("").starts_with("bw:")
1131 });
1132 let bw_session = if any_bw {
1133 let bw_askpass = targets
1134 .iter()
1135 .find_map(|h| h.askpass.as_ref().filter(|a| a.starts_with("bw:")))
1136 .cloned()
1137 .or_else(preferences::load_askpass_default);
1138 super::ensure_bw_session(None, bw_askpass.as_deref())
1139 } else {
1140 None
1141 };
1142 let target_askpass: Vec<Option<String>> =
1146 targets.iter().map(|h| h.askpass.clone()).collect();
1147 if let Some(askpass) =
1148 select_proton_askpass(&target_askpass, preferences::load_askpass_default())
1149 {
1150 super::ensure_proton_login(Some(&askpass));
1151 }
1152 let targets_info: Vec<_> = targets
1153 .iter()
1154 .map(|h| {
1155 let askpass = h.askpass.clone().or_else(preferences::load_askpass_default);
1156 super::ensure_keychain_password(&h.alias, askpass.as_deref());
1157 (h.alias.clone(), askpass)
1158 })
1159 .collect();
1160 let command = snip.command.clone();
1161 thread::spawn(move || {
1162 for (alias, askpass) in targets_info {
1163 let _ = slot_rx.recv();
1164 let slot_tx = slot_tx.clone();
1165 let tx = tx.clone();
1166 let config_path = config_path.clone();
1167 let command = command.clone();
1168 let bw_session = bw_session.clone();
1169 thread::spawn(move || {
1170 let result = snippet::run_snippet(
1171 &alias,
1172 &config_path,
1173 &command,
1174 askpass.as_deref(),
1175 bw_session.as_deref(),
1176 true,
1177 false,
1178 );
1179 let _ = tx.send((alias, result));
1180 let _ = slot_tx.send(());
1181 });
1182 }
1183 });
1184
1185 let host_count = targets.len();
1186 for _ in 0..host_count {
1187 if let Ok((alias, result)) = rx.recv() {
1188 match result {
1189 Ok(r) => {
1190 for line in r.stdout.lines() {
1191 println!("[{}] {}", alias, line);
1192 }
1193 for line in r.stderr.lines() {
1194 eprintln!("[{}] {}", alias, line);
1195 }
1196 }
1197 Err(e) => {
1198 eprintln!("{}", crate::messages::cli::host_failed(&alias, &e))
1199 }
1200 }
1201 }
1202 }
1203 } else {
1204 let mut bw_session: Option<String> = None;
1206 for host in &targets {
1207 let askpass = host
1208 .askpass
1209 .clone()
1210 .or_else(preferences::load_askpass_default);
1211 super::ensure_proton_login(askpass.as_deref());
1212 if let Some(token) =
1213 super::ensure_bw_session(bw_session.as_deref(), askpass.as_deref())
1214 {
1215 bw_session = Some(token);
1216 }
1217 super::ensure_keychain_password(&host.alias, askpass.as_deref());
1218 println!("{}", crate::messages::cli::host_separator(&host.alias));
1219 match snippet::run_snippet(
1220 &host.alias,
1221 config_path,
1222 &snip.command,
1223 askpass.as_deref(),
1224 bw_session.as_deref(),
1225 false,
1226 false,
1227 ) {
1228 Ok(r) => {
1229 if !r.status.success() {
1230 eprintln!(
1231 "{}",
1232 crate::messages::cli::exited_with_code(
1233 r.status.code().unwrap_or(1)
1234 )
1235 );
1236 }
1237 }
1238 Err(e) => {
1239 eprintln!("{}", crate::messages::cli::host_failed(&host.alias, &e))
1240 }
1241 }
1242 println!();
1243 }
1244 }
1245 Ok(())
1246 }
1247 }
1248}
1249
1250pub fn handle_logs_command(tail: bool, clear: bool) -> Result<()> {
1251 let path = logging::log_path().context("Could not determine log path")?;
1252 if clear {
1253 if path.exists() {
1254 std::fs::remove_file(&path)?;
1255 println!("{}", crate::messages::cli::log_deleted(&path.display()));
1256 } else {
1257 println!("{}", crate::messages::cli::no_log_file(&path.display()));
1258 }
1259 } else if tail {
1260 let status = std::process::Command::new("tail")
1261 .args(["-f", &path.to_string_lossy()])
1262 .status()
1263 .context("Failed to run tail")?;
1264 std::process::exit(status.code().unwrap_or(1));
1265 } else {
1266 println!("{}", path.display());
1267 }
1268 Ok(())
1269}
1270
1271pub fn handle_theme_command(command: ThemeCommands) -> Result<()> {
1272 log::info!("[purple] cli theme: dispatch");
1273 match command {
1274 ThemeCommands::List => {
1275 let current = preferences::load_theme().unwrap_or_else(|| "Purple".to_string());
1276 println!("{}", crate::messages::cli::BUILTIN_THEMES);
1277 for theme in ui::theme::ThemeDef::builtins() {
1278 let marker = if theme.name.eq_ignore_ascii_case(¤t) {
1279 "*"
1280 } else {
1281 " "
1282 };
1283 println!(" {} {}", marker, theme.name);
1284 }
1285 let custom = ui::theme::ThemeDef::load_custom();
1286 if !custom.is_empty() {
1287 println!("{}", crate::messages::cli::CUSTOM_THEMES);
1288 for theme in &custom {
1289 let marker = if theme.name.eq_ignore_ascii_case(¤t) {
1290 "*"
1291 } else {
1292 " "
1293 };
1294 println!(" {} {}", marker, theme.name);
1295 }
1296 }
1297 }
1298 ThemeCommands::Set { name } => {
1299 let found = ui::theme::ThemeDef::find_builtin(&name).or_else(|| {
1300 ui::theme::ThemeDef::load_custom()
1301 .into_iter()
1302 .find(|t| t.name.eq_ignore_ascii_case(&name))
1303 });
1304 match found {
1305 Some(theme) => {
1306 preferences::save_theme(&theme.name)?;
1307 println!("{}", crate::messages::cli::theme_set(&theme.name));
1308 }
1309 None => {
1310 anyhow::bail!("Unknown theme: {}", name);
1311 }
1312 }
1313 }
1314 }
1315 Ok(())
1316}
1317
1318pub fn handle_vault_sign_command(
1319 mut config: SshConfigFile,
1320 alias: Option<String>,
1321 all: bool,
1322 cli_vault_addr: Option<String>,
1323) -> Result<()> {
1324 log::info!(
1325 "[purple] cli vault sign: alias={:?} all={} vault_addr={:?}",
1326 alias,
1327 all,
1328 cli_vault_addr
1329 );
1330 if let Some(ref addr) = cli_vault_addr {
1331 if !vault_ssh::is_valid_vault_addr(addr) {
1332 anyhow::bail!(
1333 "Invalid --vault-addr value. Must be non-empty, no whitespace or control chars."
1334 );
1335 }
1336 }
1337 let provider_config = providers::config::ProviderConfig::load();
1338 let entries = config.host_entries();
1339
1340 if all {
1341 let mut signed = 0u32;
1342 let mut failed = 0u32;
1343 let mut skipped = 0u32;
1344
1345 for entry in &entries {
1346 let role = match vault_ssh::resolve_vault_role(
1347 entry.vault_ssh.as_deref(),
1348 entry.provider.as_deref(),
1349 entry.provider_label.as_deref(),
1350 &provider_config,
1351 ) {
1352 Some(r) => r,
1353 None => {
1354 skipped += 1;
1355 continue;
1356 }
1357 };
1358
1359 let pubkey = match vault_ssh::resolve_pubkey_path(&entry.identity_file) {
1360 Ok(p) => p,
1361 Err(e) => {
1362 println!("{}", crate::messages::cli::skipping_host(&entry.alias, &e));
1363 failed += 1;
1364 continue;
1365 }
1366 };
1367 let cert_path = vault_ssh::resolve_cert_path(&entry.alias, &entry.certificate_file)?;
1368 let status = vault_ssh::check_cert_validity(&cert_path);
1369
1370 if !vault_ssh::needs_renewal(&status) {
1371 skipped += 1;
1372 continue;
1373 }
1374
1375 let resolved_addr = cli_vault_addr.clone().or_else(|| {
1377 vault_ssh::resolve_vault_addr(
1378 entry.vault_addr.as_deref(),
1379 entry.provider.as_deref(),
1380 entry.provider_label.as_deref(),
1381 &provider_config,
1382 )
1383 });
1384 print!("{}", crate::messages::cli::vault_signing_host(&entry.alias));
1385 match vault_ssh::sign_certificate(
1386 &role,
1387 &pubkey,
1388 &entry.alias,
1389 resolved_addr.as_deref(),
1390 ) {
1391 Ok(result) => {
1392 println!("\u{2713}");
1393 if should_write_certificate_file(&entry.certificate_file) {
1396 let updated = config.set_host_certificate_file(
1397 &entry.alias,
1398 &result.cert_path.to_string_lossy(),
1399 );
1400 if !updated {
1401 eprintln!(
1402 "{}",
1403 crate::messages::cli::vault_sign_host_block_gone(&entry.alias)
1404 );
1405 }
1406 }
1407 signed += 1;
1408 }
1409 Err(e) => {
1410 println!("{}", crate::messages::cli::vault_sign_failed(&e));
1411 failed += 1;
1412 }
1413 }
1414 }
1415 if signed > 0 {
1416 if let Err(e) = config.write() {
1417 eprintln!("{}", crate::messages::cli::vault_config_update_warning(&e));
1418 }
1419 }
1420 println!(
1421 "\nSigned: {}, failed: {}, skipped (valid): {}",
1422 signed, failed, skipped
1423 );
1424 if failed > 0 {
1425 std::process::exit(1);
1426 }
1427 } else if let Some(alias) = alias {
1428 let entry = entries
1429 .iter()
1430 .find(|h| h.alias == alias)
1431 .with_context(|| format!("Host '{}' not found", alias))?;
1432
1433 let role = vault_ssh::resolve_vault_role(
1434 entry.vault_ssh.as_deref(),
1435 entry.provider.as_deref(),
1436 entry.provider_label.as_deref(),
1437 &provider_config,
1438 )
1439 .with_context(|| crate::messages::cli::vault_no_role(&alias))?;
1440
1441 let pubkey = vault_ssh::resolve_pubkey_path(&entry.identity_file)?;
1442 let resolved_addr = cli_vault_addr.clone().or_else(|| {
1443 vault_ssh::resolve_vault_addr(
1444 entry.vault_addr.as_deref(),
1445 entry.provider.as_deref(),
1446 entry.provider_label.as_deref(),
1447 &provider_config,
1448 )
1449 });
1450 let result = vault_ssh::sign_certificate(&role, &pubkey, &alias, resolved_addr.as_deref())?;
1451 if should_write_certificate_file(&entry.certificate_file) {
1455 let updated =
1456 config.set_host_certificate_file(&alias, &result.cert_path.to_string_lossy());
1457 if !updated {
1458 anyhow::bail!(
1465 "Host '{}' disappeared from ssh config before CertificateFile could be written. Cert saved to {}.",
1466 alias,
1467 result.cert_path.display()
1468 );
1469 }
1470 config
1471 .write()
1472 .with_context(|| "Failed to update SSH config with CertificateFile")?;
1473 }
1474 println!(
1475 "{}",
1476 crate::messages::cli::vault_cert_signed(&result.cert_path.display())
1477 );
1478 } else {
1479 anyhow::bail!("Provide a host alias or use --all");
1480 }
1481 Ok(())
1482}
1483
1484pub fn select_proton_askpass(
1490 target_askpass: &[Option<String>],
1491 default: Option<String>,
1492) -> Option<String> {
1493 let any_proton = target_askpass.iter().any(|a| {
1494 let resolved = a.clone().or_else(|| default.clone());
1495 resolved.as_deref().unwrap_or("").starts_with("proton:")
1496 });
1497 if !any_proton {
1498 return None;
1499 }
1500 target_askpass
1501 .iter()
1502 .find_map(|a| a.as_ref().filter(|s| s.starts_with("proton:")))
1503 .cloned()
1504 .or(default)
1505}
1506
1507pub fn run_whats_new(since: Option<&str>) -> Result<String> {
1508 use crate::changelog::{self, EntryKind};
1509 use semver::Version;
1510
1511 let current = Version::parse(env!("CARGO_PKG_VERSION"))
1512 .with_context(|| "failed to parse current version")?;
1513 let last = match since {
1514 Some(s) => Some(Version::parse(s).with_context(|| format!("invalid --since version {s}"))?),
1515 None => None,
1516 };
1517
1518 let sections = changelog::cached();
1519 let shown = changelog::versions_to_show(sections, last.as_ref(), ¤t, sections.len());
1520
1521 let mut out = String::new();
1522 out.push_str(crate::messages::cli::whats_new::HEADER);
1523 out.push_str("\n\n");
1524 for section in shown {
1525 out.push_str(&format!("## {}", section.version));
1526 if let Some(date) = §ion.date {
1527 out.push_str(&format!(" - {}", date));
1528 }
1529 out.push('\n');
1530 for entry in §ion.entries {
1531 let prefix = match entry.kind {
1532 EntryKind::Feature => "+ ",
1533 EntryKind::Change => "~ ",
1534 EntryKind::Fix => "! ",
1535 };
1536 out.push_str(prefix);
1537 out.push_str(&entry.text);
1538 out.push('\n');
1539 }
1540 out.push('\n');
1541 }
1542 Ok(out)
1543}
1544
1545#[cfg(test)]
1546mod whats_new_tests {
1547 use super::*;
1548
1549 #[test]
1550 fn whats_new_cli_outputs_header() {
1551 let output = run_whats_new(None).unwrap();
1552 assert!(output.contains("purple release notes"));
1553 }
1554
1555 #[test]
1556 fn whats_new_cli_filters_by_since() {
1557 let output = run_whats_new(Some("999.0.0")).unwrap();
1558 assert!(!output.contains("## "));
1559 }
1560
1561 #[test]
1562 fn whats_new_cli_returns_error_on_bad_version() {
1563 let result = run_whats_new(Some("not-a-version"));
1564 assert!(result.is_err());
1565 }
1566}
1567
1568#[cfg(test)]
1569mod select_proton_askpass_tests {
1570 use super::*;
1571
1572 #[test]
1573 fn returns_none_when_no_target_uses_proton_and_no_default() {
1574 let targets = vec![
1575 Some("bw:foo".to_string()),
1576 Some("keychain".to_string()),
1577 None,
1578 ];
1579 assert_eq!(select_proton_askpass(&targets, None), None);
1580 }
1581
1582 #[test]
1583 fn returns_none_when_no_target_uses_proton_and_default_is_not_proton() {
1584 let targets = vec![Some("bw:foo".to_string()), None];
1585 let default = Some("keychain".to_string());
1586 assert_eq!(select_proton_askpass(&targets, default), None);
1587 }
1588
1589 #[test]
1590 fn returns_proton_value_when_one_target_uses_proton() {
1591 let targets = vec![
1592 Some("bw:other".to_string()),
1593 Some("proton:Vault/Item/p".to_string()),
1594 None,
1595 ];
1596 assert_eq!(
1597 select_proton_askpass(&targets, None),
1598 Some("proton:Vault/Item/p".to_string())
1599 );
1600 }
1601
1602 #[test]
1603 fn prefers_first_per_host_proton_value_over_default() {
1604 let targets = vec![
1605 Some("proton:First/Item/p".to_string()),
1606 Some("proton:Second/Item/p".to_string()),
1607 ];
1608 let default = Some("proton:Default/Item/p".to_string());
1609 assert_eq!(
1610 select_proton_askpass(&targets, default),
1611 Some("proton:First/Item/p".to_string())
1612 );
1613 }
1614
1615 #[test]
1616 fn falls_back_to_default_when_no_per_host_proton_value_but_default_is_proton() {
1617 let targets = vec![None, Some("bw:foo".to_string()), None];
1618 let default = Some("proton:Default/Item/p".to_string());
1619 assert_eq!(
1620 select_proton_askpass(&targets, default),
1621 Some("proton:Default/Item/p".to_string())
1622 );
1623 }
1624
1625 #[test]
1626 fn handles_all_proton_targets() {
1627 let targets = vec![
1628 Some("proton:A/x/p".to_string()),
1629 Some("proton:B/y/p".to_string()),
1630 ];
1631 assert_eq!(
1632 select_proton_askpass(&targets, None),
1633 Some("proton:A/x/p".to_string())
1634 );
1635 }
1636
1637 #[test]
1638 fn handles_empty_target_list_with_proton_default() {
1639 let default = Some("proton:Default/Item/p".to_string());
1640 assert_eq!(select_proton_askpass(&[], default), None);
1641 }
1642
1643 #[test]
1644 fn handles_empty_target_list_with_no_default() {
1645 assert_eq!(select_proton_askpass(&[], None), None);
1646 }
1647
1648 #[test]
1649 fn empty_string_askpass_does_not_match_proton() {
1650 let targets = vec![Some(String::new()), Some("bw:foo".to_string())];
1651 assert_eq!(select_proton_askpass(&targets, None), None);
1652 }
1653}