Skip to main content

numi_cli/
lib.rs

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}