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