1pub mod cli;
2
3use std::{
4 borrow::Cow,
5 collections::BTreeSet,
6 fs,
7 io::{self, IsTerminal},
8 path::{Component, Path, PathBuf},
9};
10
11use cli::{
12 CheckArgs, Cli, Command, ConfigSubcommand, DumpContextArgs, GenerateArgs, InitArgs, LocateArgs,
13 PrintArgs,
14};
15use numi_config::{
16 CONFIG_FILE_NAME, Config, LoadedManifest, Manifest, ManifestKindSniff, WorkspaceConfig,
17 WorkspaceMember, resolve_workspace_member_config, workspace_member_config_path,
18};
19
20const STARTER_CONFIG_FALLBACK: &str = include_str!("../assets/starter-numi.toml");
21const STATUS_LABEL_WIDTH: usize = 10;
22
23#[derive(Debug)]
24pub struct CliError {
25 message: String,
26 exit_code: i32,
27}
28
29impl CliError {
30 fn new(message: impl Into<String>) -> Self {
31 Self {
32 message: message.into(),
33 exit_code: 1,
34 }
35 }
36
37 fn with_exit_code(message: impl Into<String>, exit_code: i32) -> Self {
38 Self {
39 message: message.into(),
40 exit_code,
41 }
42 }
43
44 pub fn exit_code(&self) -> i32 {
45 self.exit_code
46 }
47}
48
49impl std::fmt::Display for CliError {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(f, "{}", self.message)
52 }
53}
54
55impl std::error::Error for CliError {}
56
57pub fn run(cli: Cli) -> Result<(), CliError> {
58 let command = cli
59 .command
60 .ok_or_else(|| CliError::new("a subcommand is required"))?;
61
62 match command {
63 Command::Generate(args) => run_generate(&args),
64 Command::Check(args) => run_check(&args),
65 Command::Init(args) => run_init(&args),
66 Command::Config(config) => match config.command {
67 ConfigSubcommand::Locate(args) => run_config_locate(&args),
68 ConfigSubcommand::Print(args) => run_config_print(&args),
69 },
70 Command::DumpContext(args) => run_dump_context(&args),
71 }
72}
73
74fn run_generate(args: &GenerateArgs) -> Result<(), CliError> {
75 let loaded = load_execution_manifest(args.config.as_deref(), args.workspace)?;
76 cli_ui().manifest(&loaded.manifest, &loaded.path);
77 match &loaded.manifest {
78 Manifest::Config(config) => run_generate_config(&loaded.path, config, args),
79 Manifest::Workspace(workspace) => run_generate_workspace(&loaded.path, workspace, args),
80 }
81}
82
83fn run_generate_config(
84 config_path: &Path,
85 _config: &Config,
86 args: &GenerateArgs,
87) -> Result<(), CliError> {
88 let selected_jobs = selected_jobs(&args.jobs);
89 let incremental = args.incremental_override.resolve();
90 let report = numi_core::generate_with_options(
91 config_path,
92 selected_jobs,
93 numi_core::GenerateOptions {
94 incremental: incremental.incremental,
95 parse_cache: incremental.parse_cache,
96 force_regenerate: incremental.force_regenerate,
97 workspace_manifest_path: None,
98 },
99 )
100 .map_err(|error| CliError::new(error.to_string()))?;
101 let output_root = manifest_dir(config_path)?;
102 cli_ui().job_reports(output_root, &report.jobs);
103 print_warnings(&report.warnings);
104 let mut summary = JobSummary::default();
105 summary.record_jobs(&report.jobs);
106 cli_ui().generation_summary(summary);
107 Ok(())
108}
109
110fn run_check(args: &CheckArgs) -> Result<(), CliError> {
111 let loaded = load_execution_manifest(args.config.as_deref(), args.workspace)?;
112 cli_ui().manifest(&loaded.manifest, &loaded.path);
113 match &loaded.manifest {
114 Manifest::Config(config) => run_check_config(&loaded.path, config, args),
115 Manifest::Workspace(workspace) => run_check_workspace(&loaded.path, workspace, args),
116 }
117}
118
119fn run_check_config(
120 config_path: &Path,
121 _config: &Config,
122 args: &CheckArgs,
123) -> Result<(), CliError> {
124 let selected_jobs = selected_jobs(&args.jobs);
125
126 let report = numi_core::check(config_path, selected_jobs)
127 .map_err(|error| CliError::new(error.to_string()))?;
128 print_warnings(&report.warnings);
129
130 if report.stale_paths.is_empty() {
131 cli_ui().status(
132 StatusTone::Success,
133 "Polished",
134 "generated outputs look fresh",
135 );
136 Ok(())
137 } else {
138 let lines = report
139 .stale_paths
140 .iter()
141 .map(display_path)
142 .collect::<Vec<_>>()
143 .join("\n");
144 Err(CliError::with_exit_code(
145 format!("stale generated outputs:\n{lines}"),
146 2,
147 ))
148 }
149}
150
151fn run_dump_context(args: &DumpContextArgs) -> Result<(), CliError> {
152 let loaded = load_cli_manifest(args.config.as_deref(), false)?;
153 match &loaded.manifest {
154 Manifest::Config(_) => {
155 let report = numi_core::dump_context(&loaded.path, &args.job)
156 .map_err(|error| CliError::new(error.to_string()))?;
157 print_warnings(&report.warnings);
158 println!("{}", report.json);
159 Ok(())
160 }
161 Manifest::Workspace(_) => Err(CliError::new(
162 "`dump-context` only supports single-config manifests; run it from a member directory or pass `--config <member>/numi.toml`",
163 )),
164 }
165}
166
167fn run_init(args: &InitArgs) -> Result<(), CliError> {
168 let cwd = current_dir()?;
169 let config_path = cwd.join(CONFIG_FILE_NAME);
170
171 if config_path.exists() && !args.force {
172 return Err(CliError::new(format!(
173 "{CONFIG_FILE_NAME} already exists; pass --force to overwrite"
174 )));
175 }
176
177 let starter_config = load_starter_config()?;
178 fs::write(&config_path, starter_config.as_ref()).map_err(|error| {
179 CliError::new(format!(
180 "failed to write starter config {}: {error}",
181 config_path.display()
182 ))
183 })?;
184 cli_ui().status(
185 StatusTone::Success,
186 "Stitched",
187 format!("starter {}", display_contextual_path(&config_path)),
188 );
189
190 Ok(())
191}
192
193fn run_generate_workspace(
194 manifest_path: &Path,
195 workspace: &WorkspaceConfig,
196 args: &GenerateArgs,
197) -> Result<(), CliError> {
198 let workspace_dir = manifest_dir(manifest_path)?;
199 let mut summary = JobSummary::default();
200
201 for member in workspace.members() {
202 let member_root = workspace_member_root(&member);
203 let config_path = workspace_member_config_path(workspace_dir, &member_root);
204 let loaded_member = numi_config::load_unvalidated_from_path(&config_path)
205 .map_err(|error| CliError::new(error.to_string()))?;
206 let merged_config = resolve_workspace_member_config(
207 workspace_dir,
208 workspace,
209 &member_root,
210 &loaded_member.config,
211 )
212 .map_err(render_config_diagnostics)?;
213 let selected_jobs = workspace_jobs(args, &member);
214 let incremental = args.incremental_override.resolve();
215 let report = numi_core::generate_loaded_config(
216 &config_path,
217 &merged_config,
218 selected_jobs.as_deref(),
219 numi_core::GenerateOptions {
220 incremental: incremental.incremental,
221 parse_cache: incremental.parse_cache,
222 force_regenerate: incremental.force_regenerate,
223 workspace_manifest_path: Some(manifest_path.to_path_buf()),
224 },
225 )
226 .map_err(|error| CliError::new(error.to_string()))?;
227 cli_ui().job_reports(workspace_dir, &report.jobs);
228 print_warnings(&report.warnings);
229 summary.record_jobs(&report.jobs);
230 }
231
232 cli_ui().generation_summary(summary);
233 Ok(())
234}
235
236fn run_check_workspace(
237 manifest_path: &Path,
238 workspace: &WorkspaceConfig,
239 args: &CheckArgs,
240) -> Result<(), CliError> {
241 let workspace_dir = manifest_dir(manifest_path)?;
242 let mut stale_paths = Vec::new();
243
244 for member in workspace.members() {
245 let member_root = workspace_member_root(&member);
246 let config_path = workspace_member_config_path(workspace_dir, &member_root);
247 let loaded_member = numi_config::load_unvalidated_from_path(&config_path)
248 .map_err(|error| CliError::new(error.to_string()))?;
249 let merged_config = resolve_workspace_member_config(
250 workspace_dir,
251 workspace,
252 &member_root,
253 &loaded_member.config,
254 )
255 .map_err(render_config_diagnostics)?;
256 let selected_jobs = workspace_jobs(args, &member);
257 let report =
258 numi_core::check_loaded_config(&config_path, &merged_config, selected_jobs.as_deref())
259 .map_err(|error| CliError::new(error.to_string()))?;
260 print_warnings(&report.warnings);
261 stale_paths.extend(
262 report
263 .stale_paths
264 .iter()
265 .map(|path| normalize_workspace_stale_path(path.as_std_path(), workspace_dir)),
266 );
267 }
268
269 if stale_paths.is_empty() {
270 cli_ui().status(
271 StatusTone::Success,
272 "Polished",
273 "workspace outputs look fresh",
274 );
275 Ok(())
276 } else {
277 let lines = stale_paths
278 .iter()
279 .map(display_path)
280 .collect::<Vec<_>>()
281 .join("\n");
282 Err(CliError::with_exit_code(
283 format!("stale generated outputs:\n{lines}"),
284 2,
285 ))
286 }
287}
288
289fn run_config_locate(args: &LocateArgs) -> Result<(), CliError> {
290 let config_path = discover_config_path(args.config.as_deref())?;
291 println!("{}", display_path(&config_path));
292 Ok(())
293}
294
295fn run_config_print(args: &PrintArgs) -> Result<(), CliError> {
296 let loaded = load_cli_manifest(args.config.as_deref(), false)?;
297 match &loaded.manifest {
298 Manifest::Config(config) => {
299 let resolved = numi_config::resolve_config(config);
300 let rendered = toml::to_string_pretty(&resolved).map_err(|error| {
301 CliError::new(format!("failed to serialize config TOML: {error}"))
302 })?;
303 print!("{rendered}");
304 Ok(())
305 }
306 Manifest::Workspace(_) => Err(CliError::new(
307 "`config print` only supports single-config manifests; run it from a member directory or pass `--config <member>/numi.toml`",
308 )),
309 }
310}
311
312fn load_cli_manifest(
313 explicit_path: Option<&Path>,
314 workspace: bool,
315) -> Result<LoadedManifest, CliError> {
316 if workspace {
317 return load_workspace_cli_manifest(explicit_path);
318 }
319
320 let cwd = current_dir()?;
321 let manifest_path = numi_config::discover_config(&cwd, explicit_path)
322 .map_err(|error| CliError::new(error.to_string()))?;
323
324 numi_config::load_manifest_from_path(&manifest_path)
325 .map_err(|error| CliError::new(error.to_string()))
326}
327
328fn load_execution_manifest(
329 explicit_path: Option<&Path>,
330 workspace: bool,
331) -> Result<LoadedManifest, CliError> {
332 if workspace || explicit_path.is_some() {
333 return load_cli_manifest(explicit_path, workspace);
334 }
335
336 let cwd = current_dir()?;
337 let manifest_path = numi_config::discover_config(&cwd, None)
338 .map_err(|error| CliError::new(error.to_string()))?;
339 let manifest_kind =
340 numi_config::sniff_manifest_kind_from_path(&manifest_path).map_err(|error| {
341 CliError::new(format!(
342 "failed to read manifest {}: {error}",
343 manifest_path.display()
344 ))
345 })?;
346
347 if matches!(manifest_kind, ManifestKindSniff::ConfigLike)
348 && let Ok(workspace_loaded) = load_workspace_cli_manifest(None)
349 && workspace_loaded.path != manifest_path
350 {
351 return Ok(workspace_loaded);
352 }
353
354 numi_config::load_manifest_from_path(&manifest_path)
355 .map_err(|error| CliError::new(error.to_string()))
356}
357
358fn load_workspace_cli_manifest(explicit_path: Option<&Path>) -> Result<LoadedManifest, CliError> {
359 let cwd = current_dir()?;
360
361 if let Some(explicit_path) = explicit_path {
362 let manifest_path = numi_config::discover_workspace_ancestor(&cwd, Some(explicit_path))
363 .map_err(workspace_manifest_discovery_error)?;
364 return load_workspace_manifest_candidate(&manifest_path);
365 }
366
367 let canonical_cwd = cwd
368 .canonicalize()
369 .map_err(|error| CliError::new(format!("failed to read cwd: {error}")))?;
370
371 for directory in canonical_cwd.ancestors() {
372 let candidate = directory.join(CONFIG_FILE_NAME);
373 if !candidate.is_file() {
374 continue;
375 }
376
377 match numi_config::sniff_manifest_kind_from_path(&candidate).map_err(|error| {
378 CliError::new(format!(
379 "failed to read manifest {}: {error}",
380 candidate.display()
381 ))
382 })? {
383 ManifestKindSniff::WorkspaceLike
384 | ManifestKindSniff::BrokenWorkspaceLike
385 | ManifestKindSniff::Mixed => {
386 return load_workspace_manifest_candidate(&candidate);
387 }
388 ManifestKindSniff::ConfigLike
389 | ManifestKindSniff::Unknown
390 | ManifestKindSniff::Unparsable => continue,
391 }
392 }
393
394 Err(workspace_manifest_discovery_error(
395 numi_config::DiscoveryError::NotFound {
396 start_dir: canonical_cwd,
397 },
398 ))
399}
400
401fn require_workspace_manifest(loaded: LoadedManifest) -> Result<LoadedManifest, CliError> {
402 match loaded.manifest {
403 Manifest::Workspace(_) => Ok(loaded),
404 Manifest::Config(_) => Err(CliError::new(format!(
405 "expected a workspace manifest at {}; pass --config <workspace>/numi.toml or remove --workspace",
406 loaded.path.display()
407 ))),
408 }
409}
410
411fn load_workspace_manifest_candidate(path: &Path) -> Result<LoadedManifest, CliError> {
412 let loaded = numi_config::load_manifest_from_path(path).map_err(|error| {
413 CliError::new(format!(
414 "failed to load workspace manifest {}: {error}",
415 path.display()
416 ))
417 })?;
418 require_workspace_manifest(loaded)
419}
420
421fn workspace_manifest_discovery_error(error: numi_config::DiscoveryError) -> CliError {
422 match error {
423 numi_config::DiscoveryError::ExplicitPathNotFound(path) => CliError::new(format!(
424 "workspace manifest not found: {}\n\npass --config <workspace>/numi.toml or remove --workspace",
425 path.display()
426 )),
427 numi_config::DiscoveryError::NotFound { start_dir } => CliError::new(format!(
428 "No workspace manifest found from {}\n\nRun this from a workspace member directory with an ancestor numi.toml, or pass --config <workspace>/numi.toml",
429 start_dir.display()
430 )),
431 numi_config::DiscoveryError::Ambiguous { root, matches } => {
432 let lines = matches
433 .iter()
434 .map(|path| format!(" - {}", path.display()))
435 .collect::<Vec<_>>()
436 .join("\n");
437 CliError::new(format!(
438 "Multiple workspace manifests found under {}:\n{}\n\npass --config <workspace>/numi.toml",
439 root.display(),
440 lines
441 ))
442 }
443 numi_config::DiscoveryError::Io(error) => CliError::new(error.to_string()),
444 }
445}
446
447fn current_dir() -> Result<PathBuf, CliError> {
448 std::env::current_dir().map_err(|error| CliError::new(format!("failed to read cwd: {error}")))
449}
450
451fn load_starter_config() -> Result<Cow<'static, str>, CliError> {
452 Ok(Cow::Borrowed(STARTER_CONFIG_FALLBACK))
453}
454
455fn manifest_dir(manifest_path: &Path) -> Result<&Path, CliError> {
456 manifest_path
457 .parent()
458 .filter(|path| !path.as_os_str().is_empty())
459 .ok_or_else(|| {
460 CliError::new(format!(
461 "manifest {} has no parent directory",
462 manifest_path.display()
463 ))
464 })
465}
466
467fn discover_config_path(explicit_path: Option<&Path>) -> Result<PathBuf, CliError> {
468 let cwd = current_dir()?;
469 numi_config::discover_config(&cwd, explicit_path)
470 .map_err(|error| CliError::new(error.to_string()))
471}
472
473fn selected_jobs(jobs: &[String]) -> Option<&[String]> {
474 (!jobs.is_empty()).then_some(jobs)
475}
476
477fn workspace_member_root(member: &WorkspaceMember) -> String {
478 Path::new(&member.config)
479 .parent()
480 .filter(|path| !path.as_os_str().is_empty())
481 .map(display_path)
482 .unwrap_or_else(|| String::from("."))
483}
484
485fn workspace_member_jobs(member: &WorkspaceMember) -> Option<&[String]> {
486 (!member.jobs.is_empty()).then_some(member.jobs.as_slice())
487}
488
489fn workspace_jobs<T>(args: &T, member: &WorkspaceMember) -> Option<Vec<String>>
490where
491 T: WorkspaceJobArgs,
492{
493 match (args.selected_jobs(), workspace_member_jobs(member)) {
494 (None, None) => None,
495 (Some(cli_jobs), None) => Some(cli_jobs.to_vec()),
496 (None, Some(member_jobs)) => Some(member_jobs.to_vec()),
497 (Some(cli_jobs), Some(member_jobs)) => {
498 let allowed_jobs = member_jobs
499 .iter()
500 .map(String::as_str)
501 .collect::<BTreeSet<_>>();
502 Some(
503 cli_jobs
504 .iter()
505 .filter(|job| allowed_jobs.contains(job.as_str()))
506 .cloned()
507 .collect(),
508 )
509 }
510 }
511}
512
513fn normalize_workspace_stale_path(path: &Path, workspace_dir: &Path) -> PathBuf {
514 path.strip_prefix(workspace_dir)
515 .map(Path::to_path_buf)
516 .unwrap_or_else(|_| path.to_path_buf())
517}
518
519fn print_warnings<T: std::fmt::Display>(warnings: &[T]) {
520 for warning in warnings {
521 cli_ui().warning(&warning.to_string());
522 }
523}
524
525fn render_config_diagnostics<I, T>(diagnostics: I) -> CliError
526where
527 I: IntoIterator<Item = T>,
528 T: std::fmt::Display,
529{
530 let message = diagnostics
531 .into_iter()
532 .map(|diagnostic| diagnostic.to_string())
533 .collect::<Vec<_>>()
534 .join("\n");
535 CliError::new(message)
536}
537
538fn display_path(path: impl AsRef<Path>) -> String {
539 path.as_ref().to_string_lossy().into_owned()
540}
541
542trait WorkspaceJobArgs {
543 fn selected_jobs(&self) -> Option<&[String]>;
544}
545
546impl WorkspaceJobArgs for GenerateArgs {
547 fn selected_jobs(&self) -> Option<&[String]> {
548 selected_jobs(&self.jobs)
549 }
550}
551
552impl WorkspaceJobArgs for CheckArgs {
553 fn selected_jobs(&self) -> Option<&[String]> {
554 selected_jobs(&self.jobs)
555 }
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
559enum StatusTone {
560 Accent,
561 Success,
562 Warning,
563 Error,
564}
565
566#[derive(Debug, Clone, Copy)]
567struct CliUi {
568 interactive: bool,
569 color: bool,
570}
571
572impl CliUi {
573 fn stderr() -> Self {
574 let interactive = io::stderr().is_terminal();
575 let color = interactive && std::env::var_os("NO_COLOR").is_none();
576 Self { interactive, color }
577 }
578
579 fn manifest(&self, manifest: &Manifest, path: &Path) {
580 let kind = match manifest {
581 Manifest::Config(_) => "config",
582 Manifest::Workspace(_) => "workspace",
583 };
584 self.status(
585 StatusTone::Accent,
586 "Summoning",
587 format!("{kind} {}", display_contextual_path(path)),
588 );
589 }
590
591 fn job_reports(&self, root: &Path, jobs: &[numi_core::JobReport]) {
592 for job in jobs {
593 for hook in &job.hook_reports {
594 let (label, tone, message) = match hook.phase {
595 numi_core::HookPhase::PreGenerate => (
596 "Preparing",
597 StatusTone::Accent,
598 format!("{} hook", job.job_name),
599 ),
600 numi_core::HookPhase::PostGenerate => (
601 "Tidying",
602 StatusTone::Accent,
603 format!("{} hook", job.job_name),
604 ),
605 };
606 self.status(tone, label, message);
607 }
608
609 let (label, tone) = match job.outcome {
610 numi_core::WriteOutcome::Created => ("Stitched", StatusTone::Success),
611 numi_core::WriteOutcome::Updated => ("Restitched", StatusTone::Success),
612 numi_core::WriteOutcome::Unchanged => ("Keeping", StatusTone::Accent),
613 numi_core::WriteOutcome::Skipped => ("Skipping", StatusTone::Warning),
614 };
615 let output_path = display_relative_path(root, job.output_path.as_std_path());
616 self.status(tone, label, format!("{} -> {}", job.job_name, output_path));
617 }
618 }
619
620 fn generation_summary(&self, summary: JobSummary) {
621 if summary.total == 0 {
622 self.status(StatusTone::Accent, "Keeping", "no jobs were selected");
623 return;
624 }
625
626 let mut parts = Vec::new();
627 if summary.created > 0 {
628 parts.push(format!("{} stitched", summary.created));
629 }
630 if summary.updated > 0 {
631 parts.push(format!("{} re-stitched", summary.updated));
632 }
633 if summary.unchanged > 0 {
634 parts.push(format!("{} kept", summary.unchanged));
635 }
636 if summary.skipped > 0 {
637 parts.push(format!("{} skipped", summary.skipped));
638 }
639
640 let message = if parts.is_empty() {
641 format!("{} jobs settled", summary.total)
642 } else {
643 format!("{} jobs settled ({})", summary.total, parts.join(", "))
644 };
645 self.status(StatusTone::Success, "Polished", message);
646 }
647
648 fn warning(&self, message: &str) {
649 if self.interactive {
650 let body = message.strip_prefix("warning: ").unwrap_or(message);
651 self.block(StatusTone::Warning, "Noted", body);
652 } else {
653 eprintln!("{message}");
654 }
655 }
656
657 fn error(&self, message: &str) {
658 if self.interactive {
659 self.block(StatusTone::Error, "Oops", message);
660 } else {
661 eprintln!("{message}");
662 }
663 }
664
665 fn status(&self, tone: StatusTone, label: &str, message: impl AsRef<str>) {
666 if !self.interactive {
667 return;
668 }
669 self.block(tone, label, message.as_ref());
670 }
671
672 fn block(&self, tone: StatusTone, label: &str, message: &str) {
673 let rendered = format_status_block(label, tone, message, self.color);
674 eprint!("{rendered}");
675 }
676}
677
678fn cli_ui() -> CliUi {
679 CliUi::stderr()
680}
681
682#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
683struct JobSummary {
684 total: usize,
685 created: usize,
686 updated: usize,
687 unchanged: usize,
688 skipped: usize,
689}
690
691impl JobSummary {
692 fn record_jobs(&mut self, jobs: &[numi_core::JobReport]) {
693 for job in jobs {
694 self.record_outcome(job.outcome);
695 }
696 }
697
698 fn record_outcome(&mut self, outcome: numi_core::WriteOutcome) {
699 self.total += 1;
700 match outcome {
701 numi_core::WriteOutcome::Created => self.created += 1,
702 numi_core::WriteOutcome::Updated => self.updated += 1,
703 numi_core::WriteOutcome::Unchanged => self.unchanged += 1,
704 numi_core::WriteOutcome::Skipped => self.skipped += 1,
705 }
706 }
707}
708
709fn format_status_block(label: &str, tone: StatusTone, message: &str, color: bool) -> String {
710 let padded_label = format!("{label:>width$}", width = STATUS_LABEL_WIDTH);
711 let rendered_label = format_status_label(&padded_label, tone, color);
712 let continuation = " ".repeat(STATUS_LABEL_WIDTH);
713 let mut lines = message.lines();
714 let mut rendered = String::new();
715
716 if let Some(first_line) = lines.next() {
717 rendered.push_str(&format!("{rendered_label} {first_line}\n"));
718 } else {
719 rendered.push_str(&format!("{rendered_label}\n"));
720 }
721
722 for line in lines {
723 rendered.push_str(&format!("{continuation} {line}\n"));
724 }
725
726 rendered
727}
728
729fn format_status_label(label: &str, tone: StatusTone, color: bool) -> String {
730 if !color {
731 return label.to_string();
732 }
733
734 let code = match tone {
735 StatusTone::Accent => "36",
736 StatusTone::Success => "32",
737 StatusTone::Warning => "33",
738 StatusTone::Error => "31",
739 };
740 format!("\x1b[{code};1m{label}\x1b[0m")
741}
742
743fn display_relative_path(root: &Path, path: &Path) -> String {
744 path.strip_prefix(root)
745 .unwrap_or(path)
746 .to_string_lossy()
747 .into_owned()
748}
749
750fn display_contextual_path(path: &Path) -> String {
751 let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
752
753 if let Ok(cwd) = std::env::current_dir() {
754 let absolute_cwd = cwd.canonicalize().unwrap_or(cwd);
755 if let Some(relative) = lexical_relative_path(&absolute_path, &absolute_cwd) {
756 return display_path(relative);
757 }
758 }
759
760 display_path(absolute_path)
761}
762
763fn lexical_relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
764 let path_components = path.components().collect::<Vec<_>>();
765 let base_components = base.components().collect::<Vec<_>>();
766
767 let mut common_len = 0;
768 while common_len < path_components.len()
769 && common_len < base_components.len()
770 && path_components[common_len] == base_components[common_len]
771 {
772 common_len += 1;
773 }
774
775 if common_len == 0 {
776 return None;
777 }
778
779 let mut relative = PathBuf::new();
780 for component in &base_components[common_len..] {
781 match component {
782 Component::Normal(_) => relative.push(".."),
783 Component::CurDir => {}
784 Component::ParentDir => relative.push(".."),
785 Component::RootDir | Component::Prefix(_) => return None,
786 }
787 }
788
789 for component in &path_components[common_len..] {
790 relative.push(component.as_os_str());
791 }
792
793 if relative.as_os_str().is_empty() {
794 relative.push(".");
795 }
796
797 Some(relative)
798}
799
800pub fn print_error(error: &CliError) {
801 cli_ui().error(&error.message);
802}
803
804#[cfg(test)]
805mod cli_ui_tests {
806 use super::*;
807
808 #[test]
809 fn format_status_block_renders_single_line_plain() {
810 let rendered = format_status_block(
811 "Summoning",
812 StatusTone::Accent,
813 "workspace numi.toml",
814 false,
815 );
816 assert_eq!(rendered, " Summoning workspace numi.toml\n");
817 }
818
819 #[test]
820 fn format_status_block_indents_multiline_messages() {
821 let rendered =
822 format_status_block("Oops", StatusTone::Error, "first line\nsecond line", false);
823 assert_eq!(rendered, " Oops first line\n second line\n");
824 }
825
826 #[test]
827 fn format_status_label_wraps_color_when_enabled() {
828 let rendered = format_status_label("Stitched", StatusTone::Success, true);
829 assert!(rendered.starts_with("\u{1b}[32;1m"));
830 assert!(rendered.ends_with("\u{1b}[0m"));
831 assert!(rendered.contains("Stitched"));
832 }
833
834 #[test]
835 fn generation_summary_reports_breakdown() {
836 let mut summary = JobSummary::default();
837 summary.record_outcome(numi_core::WriteOutcome::Created);
838 summary.record_outcome(numi_core::WriteOutcome::Unchanged);
839 summary.record_outcome(numi_core::WriteOutcome::Skipped);
840 let rendered = format_status_block(
841 "Polished",
842 StatusTone::Success,
843 "3 jobs settled (1 stitched, 1 kept, 1 skipped)",
844 false,
845 );
846
847 assert_eq!(
848 rendered,
849 " Polished 3 jobs settled (1 stitched, 1 kept, 1 skipped)\n"
850 );
851 assert_eq!(
852 summary,
853 JobSummary {
854 total: 3,
855 created: 1,
856 updated: 0,
857 unchanged: 1,
858 skipped: 1,
859 }
860 );
861 }
862
863 #[test]
864 fn lexical_relative_path_walks_up_to_workspace_manifest() {
865 let path = Path::new("/tmp/workspace/numi.toml");
866 let base = Path::new("/tmp/workspace/AppUI");
867
868 let relative = lexical_relative_path(path, base).expect("relative path should resolve");
869
870 assert_eq!(relative, PathBuf::from("../numi.toml"));
871 }
872}