Skip to main content

prompter/
lib.rs

1//! Prompter: a CLI tool for composing reusable prompt snippets.
2//!
3//! This library provides functionality for managing and rendering prompt
4//! snippets from a structured library using TOML configuration files.
5#![cfg_attr(
6    test,
7    allow(
8        clippy::unwrap_used,
9        clippy::expect_used,
10        clippy::panic,
11        clippy::indexing_slicing,
12        reason = "test code uses fail-fast assertions against temp fixtures"
13    )
14)]
15
16pub mod cli;
17pub mod completions;
18pub mod config;
19pub mod doctor;
20pub mod error;
21pub mod profile;
22pub mod render;
23pub mod scaffold;
24
25pub use cli::*;
26pub use config::*;
27pub use error::PrompterError;
28pub use profile::*;
29pub use render::*;
30pub use scaffold::*;
31
32use chrono::Local;
33use clap::Parser;
34use colored::Colorize;
35use is_terminal::IsTerminal;
36use serde::Serialize;
37use std::env;
38use std::fs;
39use std::io;
40use std::path::{Path, PathBuf};
41pub use tftio_lib::{AgentSubcommand, MetaCommand};
42use tftio_lib::{JsonOutput, render_response};
43
44/// Unescape a small set of C-style backslash escapes (`\n`, `\t`, `\r`, `\"`,
45/// `\\`) in a user-supplied string, leaving unrecognized escapes intact.
46#[must_use]
47pub fn unescape(s: &str) -> String {
48    let mut out = String::with_capacity(s.len());
49    let mut chars = s.chars();
50    while let Some(c) = chars.next() {
51        if c == '\\' {
52            match chars.next() {
53                Some('n') => out.push('\n'),
54                Some('t') => out.push('\t'),
55                Some('r') => out.push('\r'),
56                Some('"') => out.push('"'),
57                Some('\\') | None => out.push('\\'),
58                Some(other) => {
59                    out.push('\\');
60                    out.push(other);
61                }
62            }
63        } else {
64            out.push(c);
65        }
66    }
67    out
68}
69
70/// Parse command-line arguments and return the resolved application mode.
71///
72/// Uses clap to parse raw arguments into a structured [`AppMode`]. The
73/// `--help` and `--version` short-circuits clap performs are mapped onto the
74/// corresponding modes rather than treated as failures.
75///
76/// # Errors
77/// Returns [`PrompterError::ArgParse`] when clap rejects the arguments
78/// (unknown flags, missing required arguments, conflicting options); the
79/// payload is clap's formatted usage text, intended for display.
80pub fn parse_args_from(args: Vec<String>) -> Result<AppMode, PrompterError> {
81    let cli = match Cli::try_parse_from(args) {
82        Ok(cli) => cli,
83        Err(err) => match err.kind() {
84            clap::error::ErrorKind::DisplayHelp => return Ok(AppMode::Help),
85            clap::error::ErrorKind::DisplayVersion => return Ok(AppMode::Version { json: false }),
86            _ => return Err(PrompterError::ArgParse(err.to_string())),
87        },
88    };
89
90    Ok(resolve_app_mode(cli))
91}
92
93/// Profile rendered as the always-on invariant base by `prompter system`.
94///
95/// This is the named invariant-base unit; the harness stamps its output into
96/// each agent's system-prompt site. Domain layers are selected separately.
97pub const SYSTEM_BASE_PROFILE: &str = "core.base";
98
99/// Resolve a parsed [`Cli`] value into the executable [`AppMode`].
100///
101/// This mapping is total: every [`Cli`] value yields an [`AppMode`], so no
102/// `Result` wrapping is needed.
103#[must_use]
104pub fn resolve_app_mode(cli: Cli) -> AppMode {
105    match cli.command {
106        Commands::Meta { command } => match command {
107            MetaCommand::Version { json } => AppMode::Version { json },
108            MetaCommand::License => AppMode::License,
109            MetaCommand::Completions { shell } => AppMode::Completions { shell },
110            MetaCommand::Doctor { json } => AppMode::Doctor { json },
111            MetaCommand::Agent { command } => AppMode::Agent { command },
112        },
113        Commands::Init => AppMode::Init,
114        Commands::List => AppMode::List {
115            config: cli.config,
116            json: cli.json,
117        },
118        Commands::Tree => AppMode::Tree {
119            config: cli.config,
120            json: cli.json,
121        },
122        Commands::Validate => AppMode::Validate {
123            config: cli.config,
124            json: cli.json,
125        },
126        Commands::Run {
127            profiles,
128            family,
129            separator,
130            pre_prompt,
131            post_prompt,
132            bare,
133        } => {
134            let sep = separator.as_ref().map(|s| unescape(s));
135            let pre = pre_prompt.as_ref().map(|s| unescape(s));
136            let post = post_prompt.as_ref().map(|s| unescape(s));
137            AppMode::Run {
138                profiles,
139                family,
140                separator: sep,
141                pre_prompt: pre,
142                post_prompt: post,
143                framing: Framing::from_bare_flag(bare),
144                config: cli.config,
145                json: cli.json,
146            }
147        }
148        Commands::System {
149            profiles,
150            separator,
151            pre_prompt,
152            post_prompt,
153            bare,
154        } => {
155            let sep = separator.as_ref().map(|s| unescape(s));
156            let pre = pre_prompt.as_ref().map(|s| unescape(s));
157            let post = post_prompt.as_ref().map(|s| unescape(s));
158            let mut all = Vec::with_capacity(profiles.len() + 1);
159            all.push(SYSTEM_BASE_PROFILE.to_string());
160            all.extend(profiles);
161            AppMode::Run {
162                profiles: all,
163                family: None,
164                separator: sep,
165                pre_prompt: pre,
166                post_prompt: post,
167                framing: Framing::from_bare_flag(bare),
168                config: cli.config,
169                json: cli.json,
170            }
171        }
172    }
173}
174
175fn home_dir() -> Result<PathBuf, PrompterError> {
176    dirs::home_dir().ok_or(PrompterError::HomeNotSet)
177}
178
179fn config_path() -> Result<PathBuf, PrompterError> {
180    Ok(home_dir()?.join(".config/prompter/config.toml"))
181}
182
183fn library_dir() -> Result<PathBuf, PrompterError> {
184    Ok(home_dir()?.join(".local/prompter/library"))
185}
186
187fn resolve_primary_config_path(path: &Path) -> Result<PathBuf, PrompterError> {
188    if path.is_absolute() {
189        Ok(path.to_path_buf())
190    } else {
191        env::current_dir()
192            .map_err(PrompterError::WorkingDir)
193            .map(|cwd| cwd.join(path))
194    }
195}
196
197fn is_terminal() -> bool {
198    std::io::stdout().is_terminal()
199}
200
201fn default_pre_prompt() -> String {
202    "You are an LLM coding agent. Here are invariants that you must adhere to. Please respond with 'Got it' when you have studied these and understand them. At that point, the operator will give you further instructions. You are *not* to do anything to the contents of this directory until you have been explicitly asked to, by the operator.\n\n".to_string()
203}
204
205fn default_post_prompt() -> String {
206    "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist.".to_string()
207}
208
209fn format_system_prefix() -> String {
210    let date = Local::now().format("%Y-%m-%d").to_string();
211    let os = env::consts::OS;
212    let arch = env::consts::ARCH;
213
214    if is_terminal() {
215        format!(
216            "đŸ—“ī¸  Today is {}, and you are running on a {}/{} system.\n\n",
217            date.bright_cyan(),
218            arch.bright_green(),
219            os.bright_green()
220        )
221    } else {
222        format!("Today is {date}, and you are running on a {arch}/{os} system.\n\n")
223    }
224}
225
226fn success_message(msg: &str) -> String {
227    if is_terminal() {
228        format!("✅ {}", msg.bright_green())
229    } else {
230        msg.to_string()
231    }
232}
233
234fn info_message(msg: &str) -> String {
235    if is_terminal() {
236        format!("â„šī¸  {}", msg.bright_blue())
237    } else {
238        msg.to_string()
239    }
240}
241
242fn read_config_with_path(path: &Path) -> Result<String, PrompterError> {
243    fs::read_to_string(path).map_err(|source| PrompterError::Io {
244        path: path.to_path_buf(),
245        source,
246    })
247}
248
249fn resolve_config_path(config_override: Option<&Path>) -> Result<PathBuf, PrompterError> {
250    config_override.map_or_else(config_path, resolve_primary_config_path)
251}
252
253/// Load the configured bundle and list its profiles to stdout.
254///
255/// # Errors
256/// Returns an error if the configuration cannot be loaded or if writing the
257/// listing to stdout fails.
258pub fn run_list_stdout(
259    config_override: Option<&Path>,
260    output: JsonOutput,
261) -> Result<(), PrompterError> {
262    let (_cfg_path, cfg) = load_bundle(config_override)?;
263    list_profiles(&cfg, output, io::stdout())?;
264    Ok(())
265}
266
267/// JSON output for successful validation
268#[derive(Debug, Serialize)]
269struct ValidateOutput {
270    valid: bool,
271}
272
273/// Validate configuration and output results to stdout.
274///
275/// Convenience function that reads configuration and validates it,
276/// outputting any errors found.
277///
278/// # Arguments
279/// * `config_override` - Optional configuration file override
280/// * `json` - Whether to output in JSON format
281///
282/// # Errors
283/// Returns an error if:
284/// - Configuration file cannot be read or parsed
285/// - Validation finds missing files or circular dependencies
286pub fn run_validate_stdout(
287    config_override: Option<&Path>,
288    output: JsonOutput,
289) -> Result<(), PrompterError> {
290    let (_cfg_path, cfg) = load_bundle(config_override)?;
291    validate(&cfg)?;
292
293    if output.is_json() {
294        let data = serde_json::to_value(ValidateOutput { valid: true })?;
295        println!(
296            "{}",
297            render_response("validate", JsonOutput::Json, data, String::new())
298        );
299    }
300
301    Ok(())
302}
303
304/// JSON structure for a single fragment
305#[derive(Debug, Serialize)]
306struct FragmentOutput {
307    path: String,
308    content: String,
309}
310
311/// JSON output structure for render command
312#[derive(Debug, Serialize)]
313struct RenderOutput {
314    profile: String,
315    pre_prompt: String,
316    system_info: String,
317    fragments: Vec<FragmentOutput>,
318}
319
320/// Return the names of all profiles available in the configured bundle, sorted.
321///
322/// # Errors
323/// Returns an error if the configuration bundle cannot be loaded.
324pub fn available_profiles(config_override: Option<&Path>) -> Result<Vec<String>, PrompterError> {
325    let (_cfg_path, cfg) = load_bundle(config_override)?;
326    let mut names: Vec<String> = cfg.profiles.keys().cloned().collect();
327    names.sort();
328    Ok(names)
329}
330
331#[cfg(test)]
332#[allow(clippy::wildcard_imports)]
333mod tests {
334    use super::*;
335    #[allow(unused_imports)]
336    use std::collections::HashSet;
337    use std::io::Write;
338
339    fn mk_tmp(prefix: &str) -> PathBuf {
340        let mut p = env::temp_dir();
341        let unique = format!(
342            "{prefix}_{}_{}",
343            std::process::id(),
344            std::time::SystemTime::now()
345                .duration_since(std::time::UNIX_EPOCH)
346                .unwrap()
347                .as_nanos()
348        );
349        p.push(unique);
350        p
351    }
352
353    /// Build a [`Config`] whose every profile shares a single library root.
354    /// Most existing unit tests only exercise the single-bundle case; this
355    /// helper avoids tediously repeating `ProfileDef { ... }` at every call site.
356    fn cfg_with_lib<I>(profiles: I, lib: &Path, post_prompt: Option<&str>) -> Config
357    where
358        I: IntoIterator<Item = (&'static str, Vec<&'static str>)>,
359    {
360        let profiles = profiles
361            .into_iter()
362            .map(|(name, deps)| {
363                (
364                    name.to_string(),
365                    ProfileDef {
366                        deps: deps.into_iter().map(String::from).collect(),
367                        library_root: lib.to_path_buf(),
368                    },
369                )
370            })
371            .collect();
372        Config {
373            profiles,
374            post_prompt: post_prompt.map(String::from),
375        }
376    }
377
378    #[test]
379    fn test_unescape() {
380        assert_eq!(unescape("a\\nb\\t\\\"\\\\c"), "a\nb\t\"\\c");
381        assert_eq!(unescape("line1\\rline2"), "line1\rline2");
382        assert_eq!(unescape("noesc"), "noesc");
383        // An unrecognized escape keeps both the backslash and the escaped char.
384        assert_eq!(unescape("a\\xb"), "a\\xb");
385        // A trailing lone backslash is preserved rather than dropped.
386        assert_eq!(unescape("end\\"), "end\\");
387    }
388
389    #[test]
390    fn test_parse_config_file_errors() {
391        // Top-level value that is not a table; toml crate should surface this.
392        let err = parse_config_file("not valid toml {{{")
393            .unwrap_err()
394            .to_string();
395        assert!(err.contains("Invalid TOML"), "err={err}");
396        // `depends_on` must be an array.
397        let err = parse_config_file("[p]\ndepends_on = \"x\"\n")
398            .unwrap_err()
399            .to_string();
400        assert!(err.contains("`depends_on`"), "err={err}");
401        // `import` must be an array of strings.
402        let err = parse_config_file("import = \"oops\"\n")
403            .unwrap_err()
404            .to_string();
405        assert!(err.contains("`import`"), "err={err}");
406    }
407
408    #[test]
409    fn test_validate_success_and_unknowns() {
410        let lib = mk_tmp("prompter_validate_ok");
411        fs::create_dir_all(&lib).unwrap();
412        fs::write(lib.join("a.md"), b"A").unwrap();
413        fs::write(lib.join("b.md"), b"B").unwrap();
414        let cfg = cfg_with_lib(
415            [("p1", vec!["a.md"]), ("p2", vec!["p1", "b.md"])],
416            &lib,
417            None,
418        );
419        assert!(validate(&cfg).is_ok());
420        let cfg2 = cfg_with_lib([("root", vec!["nope"])], &lib, None);
421        let err = validate(&cfg2).unwrap_err().to_string();
422        assert!(err.contains("Unknown profile"));
423    }
424
425    #[test]
426    fn test_resolve_errors_and_dedup() {
427        let lib = mk_tmp("prompter_resolve_errs");
428        fs::create_dir_all(&lib).unwrap();
429        let cfg = cfg_with_lib([("root", vec!["missing.md"])], &lib, None);
430        let mut seen = HashSet::new();
431        let mut stack = Vec::new();
432        let mut out = Vec::new();
433        let err = resolve_profile("root", &cfg, &mut seen, &mut stack, &mut out).unwrap_err();
434        match err {
435            ResolveError::MissingFile(_, p) => assert_eq!(p, "root"),
436            _ => panic!("expected missing file"),
437        }
438
439        fs::create_dir_all(lib.join("a")).unwrap();
440        fs::write(lib.join("a/b.md"), b"X").unwrap();
441        let cfg2 = cfg_with_lib(
442            [("A", vec!["a/b.md"]), ("B", vec!["A", "a/b.md"])],
443            &lib,
444            None,
445        );
446        let mut seen = HashSet::new();
447        let mut stack = Vec::new();
448        let mut out = Vec::new();
449        resolve_profile("B", &cfg2, &mut seen, &mut stack, &mut out).unwrap();
450        assert_eq!(out.len(), 1);
451    }
452
453    #[test]
454    fn test_parse_args_errors() {
455        // unknown flag
456        let args = vec!["prompter".into(), "--bogus".into()];
457        let err = parse_args_from(args).unwrap_err().to_string();
458        assert!(err.contains("unexpected argument"));
459        // missing required subcommand
460        let args = vec!["prompter".into()];
461        let err = parse_args_from(args).unwrap_err().to_string();
462        assert!(err.contains("Usage:") || err.contains("COMMAND"));
463    }
464
465    #[test]
466    fn test_list_profiles_order() {
467        let lib = mk_tmp("prompter_list_order");
468        fs::create_dir_all(&lib).unwrap();
469        let cfg = cfg_with_lib([("b", vec![]), ("a", vec![])], &lib, None);
470        let mut out = Vec::new();
471        super::list_profiles(&cfg, JsonOutput::Text, &mut out).unwrap();
472        assert_eq!(String::from_utf8(out).unwrap(), "a\nb\n");
473    }
474
475    #[test]
476    fn test_validate_cycle_detected() {
477        let lib = mk_tmp("prompter_cycle");
478        fs::create_dir_all(&lib).unwrap();
479        let cfg = cfg_with_lib([("A", vec!["B"]), ("B", vec!["A"])], &lib, None);
480        let err = validate(&cfg).unwrap_err().to_string();
481        assert!(err.contains("Cycle detected"));
482    }
483
484    #[test]
485    fn test_parse_config_file_flattens_dotted_tables() {
486        // Preserves pre-existing semantics: `[profile.x]` is a flat profile
487        // named "profile.x", not a nested table.
488        let cfg = r#"
489[profile.x]
490depends_on = [
491  "a/b.md",
492  "c/d.md",
493  "e/f.md",
494]
495"#;
496        let parsed = parse_config_file(cfg).unwrap();
497        assert_eq!(parsed.profiles.get("profile.x").unwrap().len(), 3);
498    }
499
500    #[test]
501    fn test_render_to_writer_basic() {
502        let lib = mk_tmp("prompter_render_to_writer");
503        fs::create_dir_all(lib.join("a")).unwrap();
504        fs::create_dir_all(lib.join("f")).unwrap();
505        fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
506        fs::write(lib.join("f/y.md"), b"FY\n").unwrap();
507        let cfg = cfg_with_lib(
508            [
509                ("child", vec!["a/x.md"]),
510                ("root", vec!["child", "f/y.md", "a/x.md"]),
511            ],
512            &lib,
513            None,
514        );
515        let mut out = Vec::new();
516        super::render_to_writer(
517            &cfg,
518            &mut out,
519            &["root".to_string()],
520            None,
521            Some("\n--\n"),
522            None,
523            None,
524            Framing::Full,
525            JsonOutput::Text,
526        )
527        .unwrap();
528
529        let output_str = String::from_utf8(out).unwrap();
530        assert!(output_str.starts_with("You are an LLM coding agent."));
531        assert!(output_str.contains("Today is "));
532        assert!(output_str.contains(", and you are running on a "));
533        assert!(output_str.contains(" system.\n\n"));
534        assert!(output_str.contains("AX\n"));
535        assert!(output_str.contains("\n--\n"));
536        assert!(output_str.contains("FY\n"));
537        assert!(output_str.ends_with(
538            "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
539        ));
540    }
541
542    #[test]
543    fn test_render_to_writer_bare_omits_framing() {
544        let lib = mk_tmp("prompter_render_bare");
545        fs::create_dir_all(lib.join("a")).unwrap();
546        fs::create_dir_all(lib.join("f")).unwrap();
547        fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
548        fs::write(lib.join("f/y.md"), b"FY\n").unwrap();
549        let cfg = cfg_with_lib(
550            [
551                ("child", vec!["a/x.md"]),
552                ("root", vec!["child", "f/y.md", "a/x.md"]),
553            ],
554            &lib,
555            Some("Config post-prompt"),
556        );
557        let mut out = Vec::new();
558        super::render_to_writer(
559            &cfg,
560            &mut out,
561            &["root".to_string()],
562            None,
563            Some("\n--\n"),
564            None,
565            None,
566            Framing::Bare,
567            JsonOutput::Text,
568        )
569        .unwrap();
570
571        let output_str = String::from_utf8(out).unwrap();
572        // No pre-prompt, no date/system context, no post-prompt.
573        assert!(!output_str.starts_with("You are an LLM coding agent."));
574        assert!(!output_str.contains("Today is "));
575        assert!(!output_str.contains("Config post-prompt"));
576        assert!(!output_str.contains(
577            "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
578        ));
579        // Fragments (deduplicated) and the separator are still present, and the
580        // output begins directly with the first fragment (no leading newline).
581        assert!(output_str.starts_with("AX\n"));
582        assert!(output_str.contains("\n--\n"));
583        assert!(output_str.contains("FY\n"));
584        assert_eq!(output_str.matches("AX\n").count(), 1);
585    }
586
587    #[test]
588    fn test_render_to_writer_bare_honors_explicit_pre_post() {
589        let lib = mk_tmp("prompter_render_bare_explicit");
590        fs::create_dir_all(lib.join("a")).unwrap();
591        fs::create_dir_all(lib.join("f")).unwrap();
592        fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
593        fs::write(lib.join("f/y.md"), b"FY\n").unwrap();
594        let cfg = cfg_with_lib(
595            [("child", vec!["a/x.md"]), ("root", vec!["child", "f/y.md"])],
596            &lib,
597            Some("Config post-prompt"),
598        );
599        let mut out = Vec::new();
600        super::render_to_writer(
601            &cfg,
602            &mut out,
603            &["root".to_string()],
604            None,
605            None,
606            Some("EXPLICIT-PRE"),
607            Some("EXPLICIT-POST"),
608            Framing::Bare,
609            JsonOutput::Text,
610        )
611        .unwrap();
612
613        let output_str = String::from_utf8(out).unwrap();
614        // Explicit pre/post win even in bare framing; the config post-prompt and
615        // the date/system stamp are still suppressed.
616        assert!(output_str.starts_with("EXPLICIT-PRE"));
617        assert!(output_str.ends_with("EXPLICIT-POST"));
618        assert!(!output_str.contains("Config post-prompt"));
619        assert!(!output_str.contains("Today is "));
620        assert!(output_str.contains("AX\n"));
621        assert!(output_str.contains("FY\n"));
622    }
623
624    #[test]
625    fn test_render_to_writer_custom_pre_prompt() {
626        let lib = mk_tmp("prompter_render_custom_pre");
627        fs::create_dir_all(lib.join("a")).unwrap();
628        fs::write(lib.join("a/x.md"), b"Content\n").unwrap();
629        let cfg = cfg_with_lib([("test", vec!["a/x.md"])], &lib, None);
630        let mut out = Vec::new();
631        super::render_to_writer(
632            &cfg,
633            &mut out,
634            &["test".to_string()],
635            None,
636            None,
637            Some("Custom pre-prompt\n\n"),
638            None,
639            Framing::Full,
640            JsonOutput::Text,
641        )
642        .unwrap();
643
644        let output_str = String::from_utf8(out).unwrap();
645        assert!(output_str.starts_with("Custom pre-prompt\n\n"));
646        assert!(output_str.contains("Today is "));
647        assert!(output_str.contains("Content\n"));
648        assert!(output_str.ends_with(
649            "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
650        ));
651    }
652
653    #[test]
654    fn test_render_to_writer_custom_post_prompt() {
655        let lib = mk_tmp("prompter_render_custom_post");
656        fs::create_dir_all(lib.join("a")).unwrap();
657        fs::write(lib.join("a/x.md"), b"Content\n").unwrap();
658        let cfg = cfg_with_lib(
659            [("test", vec!["a/x.md"])],
660            &lib,
661            Some("Custom config post-prompt"),
662        );
663        let mut out = Vec::new();
664        super::render_to_writer(
665            &cfg,
666            &mut out,
667            &["test".to_string()],
668            None,
669            None,
670            None,
671            None,
672            Framing::Full,
673            JsonOutput::Text,
674        )
675        .unwrap();
676
677        let output_str = String::from_utf8(out).unwrap();
678        assert!(output_str.ends_with("Custom config post-prompt"));
679
680        let mut out2 = Vec::new();
681        super::render_to_writer(
682            &cfg,
683            &mut out2,
684            &["test".to_string()],
685            None,
686            None,
687            None,
688            Some("CLI post-prompt"),
689            Framing::Full,
690            JsonOutput::Text,
691        )
692        .unwrap();
693
694        let output_str2 = String::from_utf8(out2).unwrap();
695        assert!(output_str2.ends_with("CLI post-prompt"));
696    }
697
698    #[test]
699    fn test_render_multiple_profiles_with_deduplication() {
700        let lib = mk_tmp("prompter_multi_profile_dedup");
701        fs::create_dir_all(lib.join("shared")).unwrap();
702        fs::create_dir_all(lib.join("a")).unwrap();
703        fs::create_dir_all(lib.join("b")).unwrap();
704
705        fs::write(lib.join("shared/common.md"), b"COMMON\n").unwrap();
706        fs::write(lib.join("a/specific.md"), b"A_SPECIFIC\n").unwrap();
707        fs::write(lib.join("b/specific.md"), b"B_SPECIFIC\n").unwrap();
708
709        let cfg = cfg_with_lib(
710            [
711                ("profile_a", vec!["shared/common.md", "a/specific.md"]),
712                ("profile_b", vec!["shared/common.md", "b/specific.md"]),
713            ],
714            &lib,
715            None,
716        );
717
718        let mut out = Vec::new();
719        super::render_to_writer(
720            &cfg,
721            &mut out,
722            &["profile_a".to_string(), "profile_b".to_string()],
723            None,
724            Some("\n---\n"),
725            None,
726            None,
727            Framing::Full,
728            JsonOutput::Text,
729        )
730        .unwrap();
731
732        let output_str = String::from_utf8(out).unwrap();
733
734        let common_count = output_str.matches("COMMON").count();
735        assert_eq!(
736            common_count, 1,
737            "Common file should appear exactly once, found {common_count}"
738        );
739        assert!(output_str.contains("A_SPECIFIC"));
740        assert!(output_str.contains("B_SPECIFIC"));
741
742        let common_pos = output_str.find("COMMON").unwrap();
743        let a_pos = output_str.find("A_SPECIFIC").unwrap();
744        let b_pos = output_str.find("B_SPECIFIC").unwrap();
745
746        assert!(common_pos < a_pos);
747        assert!(a_pos < b_pos);
748    }
749
750    #[test]
751    fn test_family_variant_substitution_fallback_and_neutral_dedup() {
752        let lib = mk_tmp("prompter_family_substitution");
753        fs::create_dir_all(lib.join("general/families/gpt")).unwrap();
754        fs::write(lib.join("general/rules.md"), b"NEUTRAL_RULES\n").unwrap();
755        fs::write(lib.join("general/fallback.md"), b"FALLBACK\n").unwrap();
756        fs::write(lib.join("general/families/gpt/rules.md"), b"GPT_RULES\n").unwrap();
757        let cfg = cfg_with_lib(
758            [
759                ("first", vec!["general/rules.md", "general/fallback.md"]),
760                ("second", vec!["general/rules.md"]),
761            ],
762            &lib,
763            None,
764        );
765        let family = FamilyName::new("gpt").unwrap();
766
767        let mut family_output = Vec::new();
768        super::render_to_writer(
769            &cfg,
770            &mut family_output,
771            &["first".to_string(), "second".to_string()],
772            Some(&family),
773            None,
774            None,
775            None,
776            Framing::Bare,
777            JsonOutput::Text,
778        )
779        .unwrap();
780        let family_output = String::from_utf8(family_output).unwrap();
781        assert_eq!(family_output.matches("GPT_RULES").count(), 1);
782        assert!(!family_output.contains("NEUTRAL_RULES"));
783        assert!(family_output.contains("FALLBACK"));
784
785        let mut neutral_output = Vec::new();
786        super::render_to_writer(
787            &cfg,
788            &mut neutral_output,
789            &["first".to_string(), "second".to_string()],
790            None,
791            None,
792            None,
793            None,
794            Framing::Bare,
795            JsonOutput::Text,
796        )
797        .unwrap();
798        let neutral_output = String::from_utf8(neutral_output).unwrap();
799        assert_eq!(neutral_output.matches("NEUTRAL_RULES").count(), 1);
800        assert!(!neutral_output.contains("GPT_RULES"));
801        assert!(neutral_output.contains("FALLBACK"));
802    }
803
804    #[test]
805    fn test_validate_rejects_orphan_family_variant() {
806        let lib = mk_tmp("prompter_family_orphan");
807        fs::create_dir_all(lib.join("general/families/gpt")).unwrap();
808        fs::write(lib.join("general/rules.md"), b"NEUTRAL_RULES\n").unwrap();
809        fs::write(lib.join("general/families/gpt/rules.md"), b"GPT_RULES\n").unwrap();
810        let cfg = cfg_with_lib([("root", vec!["general/rules.md"])], &lib, None);
811        assert!(validate(&cfg).is_ok());
812
813        let orphan = lib.join("general/families/gpt/orphan.md");
814        fs::write(&orphan, b"ORPHAN\n").unwrap();
815        let error = validate(&cfg).unwrap_err().to_string();
816        assert!(error.contains("Orphan family variant"), "error: {error}");
817        assert!(
818            error.contains(&orphan.display().to_string()),
819            "error: {error}"
820        );
821    }
822
823    #[test]
824    fn test_parse_config_file_with_post_prompt() {
825        let cfg = r#"
826post_prompt = "Custom post prompt from config"
827
828[profile]
829depends_on = ["file.md"]
830"#;
831        let parsed = parse_config_file(cfg).unwrap();
832        assert_eq!(
833            parsed.post_prompt,
834            Some("Custom post prompt from config".to_string())
835        );
836        assert_eq!(parsed.profiles.get("profile").unwrap().len(), 1);
837    }
838
839    #[test]
840    fn test_expand_tilde() {
841        // Compare against the same home source the implementation uses
842        // (`dirs::home_dir`) rather than reading `$HOME` directly, and never
843        // touch any real files.
844        if let Some(home) = dirs::home_dir() {
845            assert_eq!(expand_tilde("~/foo/bar").unwrap(), home.join("foo/bar"));
846            assert_eq!(expand_tilde("~").unwrap(), home);
847        }
848        assert_eq!(
849            expand_tilde("/abs/path").unwrap(),
850            PathBuf::from("/abs/path")
851        );
852        assert_eq!(expand_tilde("rel/path").unwrap(), PathBuf::from("rel/path"));
853    }
854
855    #[test]
856    fn test_load_bundle_single_file() {
857        let dir = mk_tmp("prompter_bundle_single");
858        fs::create_dir_all(dir.join("library/a")).unwrap();
859        fs::write(dir.join("library/a/x.md"), b"AX").unwrap();
860        fs::write(
861            dir.join("config.toml"),
862            r#"
863[root]
864depends_on = ["a/x.md"]
865"#,
866        )
867        .unwrap();
868        let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
869        assert_eq!(cfg.profiles.len(), 1);
870        let root = cfg.profiles.get("root").unwrap();
871        assert_eq!(root.deps, vec!["a/x.md"]);
872        // library_root should resolve to <config-dir>/library
873        assert_eq!(
874            root.library_root,
875            fs::canonicalize(dir.join("library")).unwrap()
876        );
877    }
878
879    #[test]
880    fn test_load_bundle_imports_and_dedup_across_libraries() {
881        // primary config + one imported bundle; each has its own library.
882        let primary_dir = mk_tmp("prompter_bundle_primary");
883        let imported_dir = mk_tmp("prompter_bundle_import");
884
885        fs::create_dir_all(primary_dir.join("library/p")).unwrap();
886        fs::write(primary_dir.join("library/p/primary.md"), b"P").unwrap();
887
888        fs::create_dir_all(imported_dir.join("library/i")).unwrap();
889        fs::write(imported_dir.join("library/i/imported.md"), b"I").unwrap();
890
891        fs::write(
892            imported_dir.join("config.toml"),
893            r#"
894[team.base]
895depends_on = ["i/imported.md"]
896"#,
897        )
898        .unwrap();
899
900        let primary_cfg = format!(
901            r#"
902import = ["{}"]
903
904[my.local]
905depends_on = ["team.base", "p/primary.md"]
906"#,
907            imported_dir.join("config.toml").display()
908        );
909        fs::write(primary_dir.join("config.toml"), primary_cfg).unwrap();
910
911        let cfg = load_config_bundle(&primary_dir.join("config.toml"), None).unwrap();
912        assert_eq!(cfg.profiles.len(), 2);
913        assert_eq!(
914            cfg.profiles.get("team.base").unwrap().library_root,
915            fs::canonicalize(imported_dir.join("library")).unwrap()
916        );
917        assert_eq!(
918            cfg.profiles.get("my.local").unwrap().library_root,
919            fs::canonicalize(primary_dir.join("library")).unwrap()
920        );
921
922        // Resolution finds both fragments, each in its own library.
923        let mut seen = HashSet::new();
924        let mut stack = Vec::new();
925        let mut out = Vec::new();
926        resolve_profile("my.local", &cfg, &mut seen, &mut stack, &mut out).unwrap();
927        assert_eq!(out.len(), 2);
928    }
929
930    #[test]
931    fn test_load_bundle_duplicate_profile_name_across_imports() {
932        let primary_dir = mk_tmp("prompter_bundle_dup_primary");
933        let imported_dir = mk_tmp("prompter_bundle_dup_import");
934
935        fs::create_dir_all(primary_dir.join("library")).unwrap();
936        fs::create_dir_all(imported_dir.join("library")).unwrap();
937
938        fs::write(
939            imported_dir.join("config.toml"),
940            "\n[clash]\ndepends_on = []\n",
941        )
942        .unwrap();
943
944        let primary_cfg = format!(
945            "\nimport = [\"{}\"]\n\n[clash]\ndepends_on = []\n",
946            imported_dir.join("config.toml").display()
947        );
948        fs::write(primary_dir.join("config.toml"), primary_cfg).unwrap();
949
950        let err = load_config_bundle(&primary_dir.join("config.toml"), None)
951            .unwrap_err()
952            .to_string();
953        assert!(err.contains("Duplicate profile `clash`"), "err={err}");
954    }
955
956    #[test]
957    fn test_load_bundle_import_cycle() {
958        let a_dir = mk_tmp("prompter_cycle_a");
959        let b_dir = mk_tmp("prompter_cycle_b");
960        fs::create_dir_all(a_dir.join("library")).unwrap();
961        fs::create_dir_all(b_dir.join("library")).unwrap();
962
963        let a_path = a_dir.join("config.toml");
964        let b_path = b_dir.join("config.toml");
965        fs::write(&a_path, format!("import = [\"{}\"]\n", b_path.display())).unwrap();
966        fs::write(&b_path, format!("import = [\"{}\"]\n", a_path.display())).unwrap();
967
968        let err = load_config_bundle(&a_path, None).unwrap_err().to_string();
969        assert!(err.contains("Import cycle"), "err={err}");
970    }
971
972    #[test]
973    fn test_load_bundle_explicit_library_key() {
974        let dir = mk_tmp("prompter_bundle_explicit_lib");
975        fs::create_dir_all(dir.join("alt_library/sub")).unwrap();
976        fs::write(dir.join("alt_library/sub/x.md"), b"X").unwrap();
977        fs::write(
978            dir.join("config.toml"),
979            r#"
980library = "alt_library"
981
982[p]
983depends_on = ["sub/x.md"]
984"#,
985        )
986        .unwrap();
987
988        let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
989        let expected = fs::canonicalize(dir.join("alt_library")).unwrap();
990        assert_eq!(cfg.profiles.get("p").unwrap().library_root, expected);
991    }
992
993    #[test]
994    fn test_load_bundle_import_post_prompt_only_from_primary() {
995        let primary_dir = mk_tmp("prompter_pp_primary");
996        let imported_dir = mk_tmp("prompter_pp_import");
997        fs::create_dir_all(primary_dir.join("library")).unwrap();
998        fs::create_dir_all(imported_dir.join("library")).unwrap();
999
1000        fs::write(
1001            imported_dir.join("config.toml"),
1002            r#"
1003post_prompt = "from imported"
1004"#,
1005        )
1006        .unwrap();
1007        let primary_cfg = format!(
1008            r#"
1009import = ["{}"]
1010post_prompt = "from primary"
1011"#,
1012            imported_dir.join("config.toml").display()
1013        );
1014        fs::write(primary_dir.join("config.toml"), primary_cfg).unwrap();
1015        let cfg = load_config_bundle(&primary_dir.join("config.toml"), None).unwrap();
1016        assert_eq!(cfg.post_prompt.as_deref(), Some("from primary"));
1017
1018        // Imported's post_prompt is ignored even if primary has none.
1019        fs::write(
1020            primary_dir.join("config.toml"),
1021            format!(
1022                r#"
1023import = ["{}"]
1024"#,
1025                imported_dir.join("config.toml").display()
1026            ),
1027        )
1028        .unwrap();
1029        let cfg2 = load_config_bundle(&primary_dir.join("config.toml"), None).unwrap();
1030        assert!(cfg2.post_prompt.is_none());
1031    }
1032
1033    fn expect_run(args: Vec<String>) -> AppMode {
1034        let mode = parse_args_from(args).unwrap();
1035        assert!(matches!(mode, AppMode::Run { .. }), "expected run");
1036        mode
1037    }
1038
1039    #[test]
1040    fn parse_args_run_with_separator() {
1041        let args = vec![
1042            "prompter".into(),
1043            "run".into(),
1044            "--separator".into(),
1045            "\\n--\\n".into(),
1046            "profile".into(),
1047        ];
1048        let AppMode::Run {
1049            profiles,
1050            family,
1051            separator,
1052            pre_prompt,
1053            post_prompt,
1054            framing,
1055            config,
1056            json,
1057        } = expect_run(args)
1058        else {
1059            unreachable!()
1060        };
1061        assert_eq!(profiles, vec!["profile".to_string()]);
1062        assert_eq!(family, None);
1063        assert_eq!(separator, Some("\n--\n".into()));
1064        assert_eq!(pre_prompt, None);
1065        assert_eq!(post_prompt, None);
1066        assert_eq!(framing, Framing::Full);
1067        assert!(config.is_none());
1068        assert!(!json);
1069    }
1070
1071    #[test]
1072    fn parse_args_run_with_family() {
1073        let args = vec![
1074            "prompter".into(),
1075            "run".into(),
1076            "--family".into(),
1077            "gpt".into(),
1078            "profile".into(),
1079        ];
1080        let AppMode::Run {
1081            profiles, family, ..
1082        } = expect_run(args)
1083        else {
1084            unreachable!()
1085        };
1086        assert_eq!(profiles, vec!["profile".to_string()]);
1087        assert_eq!(family, Some(FamilyName::new("gpt").unwrap()));
1088    }
1089
1090    #[test]
1091    fn parse_args_rejects_family_path_traversal() {
1092        let args = vec![
1093            "prompter".into(),
1094            "run".into(),
1095            "--family".into(),
1096            "../gpt".into(),
1097            "profile".into(),
1098        ];
1099        let error = parse_args_from(args).unwrap_err().to_string();
1100        assert!(error.contains("family name must be one non-empty path component"));
1101    }
1102
1103    #[test]
1104    fn parse_args_run_with_pre_prompt() {
1105        let args = vec![
1106            "prompter".into(),
1107            "run".into(),
1108            "--pre-prompt".into(),
1109            "Custom pre-prompt".into(),
1110            "profile".into(),
1111        ];
1112        let AppMode::Run {
1113            profiles,
1114            separator,
1115            pre_prompt,
1116            ..
1117        } = expect_run(args)
1118        else {
1119            unreachable!()
1120        };
1121        assert_eq!(profiles, vec!["profile".to_string()]);
1122        assert_eq!(separator, None);
1123        assert_eq!(pre_prompt, Some("Custom pre-prompt".into()));
1124    }
1125
1126    #[test]
1127    fn parse_args_run_with_bare_flag() {
1128        let args = vec![
1129            "prompter".into(),
1130            "run".into(),
1131            "--bare".into(),
1132            "profile".into(),
1133        ];
1134        let AppMode::Run {
1135            profiles, framing, ..
1136        } = expect_run(args)
1137        else {
1138            unreachable!()
1139        };
1140        assert_eq!(profiles, vec!["profile".to_string()]);
1141        assert_eq!(framing, Framing::Bare);
1142    }
1143
1144    #[test]
1145    fn parse_args_system_with_bare_flag() {
1146        let args = vec![
1147            "prompter".into(),
1148            "system".into(),
1149            "--bare".into(),
1150            "extra".into(),
1151        ];
1152        let AppMode::Run {
1153            profiles, framing, ..
1154        } = expect_run(args)
1155        else {
1156            unreachable!()
1157        };
1158        // System prepends the system base profile before any extras.
1159        assert_eq!(
1160            profiles,
1161            vec![SYSTEM_BASE_PROFILE.to_string(), "extra".to_string()]
1162        );
1163        assert_eq!(framing, Framing::Bare);
1164    }
1165
1166    #[test]
1167    fn parse_args_run_with_multiple_profiles() {
1168        let args = vec![
1169            "prompter".into(),
1170            "run".into(),
1171            "profile1".into(),
1172            "profile2".into(),
1173            "profile3.nested".into(),
1174        ];
1175        let AppMode::Run { profiles, .. } = expect_run(args) else {
1176            unreachable!()
1177        };
1178        assert_eq!(
1179            profiles,
1180            vec![
1181                "profile1".to_string(),
1182                "profile2".to_string(),
1183                "profile3.nested".to_string(),
1184            ]
1185        );
1186    }
1187
1188    #[test]
1189    fn parse_args_bare_subcommands() {
1190        let args = vec!["prompter".into(), "list".into()];
1191        assert!(matches!(
1192            parse_args_from(args).unwrap(),
1193            AppMode::List {
1194                config: None,
1195                json: false
1196            }
1197        ));
1198        let args = vec!["prompter".into(), "validate".into()];
1199        assert!(matches!(
1200            parse_args_from(args).unwrap(),
1201            AppMode::Validate {
1202                config: None,
1203                json: false
1204            }
1205        ));
1206        let args = vec!["prompter".into(), "init".into()];
1207        assert!(matches!(parse_args_from(args).unwrap(), AppMode::Init));
1208        let args = vec!["prompter".into(), "meta".into(), "version".into()];
1209        assert!(matches!(
1210            parse_args_from(args).unwrap(),
1211            AppMode::Version { json: false }
1212        ));
1213    }
1214
1215    #[test]
1216    fn parse_args_config_before_subcommand() {
1217        let args = vec![
1218            "prompter".into(),
1219            "--config".into(),
1220            "custom/config.toml".into(),
1221            "list".into(),
1222        ];
1223        let AppMode::List { config, json } = parse_args_from(args).unwrap() else {
1224            panic!("expected list mode");
1225        };
1226        assert_eq!(config, Some(PathBuf::from("custom/config.toml")));
1227        assert!(!json);
1228    }
1229
1230    #[test]
1231    fn parse_args_config_after_run_subcommand() {
1232        let args = vec![
1233            "prompter".into(),
1234            "run".into(),
1235            "--config".into(),
1236            "custom/config.toml".into(),
1237            "profile".into(),
1238        ];
1239        let AppMode::Run { config, json, .. } = parse_args_from(args).unwrap() else {
1240            panic!("expected run mode");
1241        };
1242        assert_eq!(config, Some(PathBuf::from("custom/config.toml")));
1243        assert!(!json);
1244    }
1245
1246    struct FailAfterN {
1247        writes_done: usize,
1248        fail_on: usize,
1249    }
1250
1251    impl Write for FailAfterN {
1252        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1253            self.writes_done += 1;
1254            if self.writes_done == self.fail_on {
1255                Err(io::Error::other("synthetic write failure"))
1256            } else {
1257                Ok(buf.len())
1258            }
1259        }
1260        fn flush(&mut self) -> io::Result<()> {
1261            Ok(())
1262        }
1263    }
1264
1265    #[test]
1266    fn test_render_to_writer_write_error_on_separator() {
1267        let lib = mk_tmp("prompter_write_err_sep");
1268        fs::create_dir_all(lib.join("a")).unwrap();
1269        fs::write(lib.join("a/x.md"), b"AX").unwrap();
1270        fs::write(lib.join("a/y.md"), b"AY").unwrap();
1271        let cfg = cfg_with_lib([("p", vec!["a/x.md", "a/y.md"])], &lib, None);
1272        let mut w = FailAfterN {
1273            writes_done: 0,
1274            fail_on: 3,
1275        }; // pre-prompt ok, system prefix ok, fail on separator
1276        let err = super::render_to_writer(
1277            &cfg,
1278            &mut w,
1279            &["p".to_string()],
1280            None,
1281            Some("--"),
1282            None,
1283            None,
1284            Framing::Full,
1285            JsonOutput::Text,
1286        )
1287        .unwrap_err()
1288        .to_string();
1289        assert!(err.contains("Write error"), "err={err}");
1290    }
1291
1292    #[test]
1293    fn test_render_to_writer_write_error_on_file() {
1294        let lib = mk_tmp("prompter_write_err_file");
1295        fs::create_dir_all(lib.join("a")).unwrap();
1296        fs::write(lib.join("a/x.md"), b"AX").unwrap();
1297        let cfg = cfg_with_lib([("p", vec!["a/x.md"])], &lib, None);
1298        let mut w = FailAfterN {
1299            writes_done: 0,
1300            fail_on: 1,
1301        }; // fail on first write (pre-prompt)
1302        let err = super::render_to_writer(
1303            &cfg,
1304            &mut w,
1305            &["p".to_string()],
1306            None,
1307            Some("--"),
1308            None,
1309            None,
1310            Framing::Full,
1311            JsonOutput::Text,
1312        )
1313        .unwrap_err()
1314        .to_string();
1315        assert!(err.contains("Write error"), "err={err}");
1316    }
1317
1318    #[test]
1319    fn run_list_and_validate_via_config_override() {
1320        // Exercise the stdout list/validate entry points through an explicit
1321        // `-c` override so no real `$HOME` or user files are involved.
1322        let dir = mk_tmp("prompter_list_validate_ok");
1323        fs::create_dir_all(dir.join("library/a")).unwrap();
1324        fs::create_dir_all(dir.join("library/f")).unwrap();
1325        fs::write(dir.join("library/a/x.md"), b"AX\n").unwrap();
1326        fs::write(dir.join("library/f/y.md"), b"FY\n").unwrap();
1327        let cfg =
1328            "[child]\ndepends_on = [\"a/x.md\"]\n\n[root]\ndepends_on = [\"child\", \"f/y.md\"]\n";
1329        fs::write(dir.join("config.toml"), cfg).unwrap();
1330        let cfg_path = dir.join("config.toml");
1331        assert!(run_validate_stdout(Some(&cfg_path), JsonOutput::Text).is_ok());
1332        assert!(run_list_stdout(Some(&cfg_path), JsonOutput::Text).is_ok());
1333    }
1334
1335    #[test]
1336    fn run_validate_via_config_override_reports_failures() {
1337        let dir = mk_tmp("prompter_list_validate_bad");
1338        fs::create_dir_all(dir.join("library")).unwrap();
1339        let cfg = "[root]\ndepends_on = [\"missing.md\", \"unknown_profile\"]\n";
1340        fs::write(dir.join("config.toml"), cfg).unwrap();
1341        let err =
1342            run_validate_stdout(Some(&dir.join("config.toml")), JsonOutput::Text).unwrap_err();
1343        assert!(
1344            err.to_string().contains("Missing file") && err.to_string().contains("Unknown profile"),
1345            "err={err}"
1346        );
1347    }
1348
1349    #[test]
1350    fn render_to_vec_composes_fragments_from_override_config() {
1351        let dir = mk_tmp("prompter_render_to_vec");
1352        fs::create_dir_all(dir.join("library/a")).unwrap();
1353        fs::write(dir.join("library/a/x.md"), b"XCONTENT\n").unwrap();
1354        fs::write(
1355            dir.join("config.toml"),
1356            "[root]\ndepends_on = [\"a/x.md\"]\n",
1357        )
1358        .unwrap();
1359        let bytes =
1360            render_to_vec(&["root".to_string()], None, Some(&dir.join("config.toml"))).unwrap();
1361        let text = String::from_utf8(bytes).unwrap();
1362        assert!(text.starts_with("You are an LLM coding agent."));
1363        assert!(text.contains("XCONTENT\n"));
1364        assert!(text.ends_with(
1365            "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
1366        ));
1367    }
1368
1369    #[test]
1370    fn available_profiles_returns_sorted_names() {
1371        let dir = mk_tmp("prompter_available_profiles");
1372        fs::create_dir_all(dir.join("library")).unwrap();
1373        fs::write(
1374            dir.join("config.toml"),
1375            "[zebra]\ndepends_on = []\n\n[alpha]\ndepends_on = []\n",
1376        )
1377        .unwrap();
1378        let names = available_profiles(Some(&dir.join("config.toml"))).unwrap();
1379        assert_eq!(names, vec!["alpha".to_string(), "zebra".to_string()]);
1380    }
1381
1382    #[test]
1383    fn show_tree_text_renders_connectors_and_blank_lines() {
1384        let lib = mk_tmp("prompter_show_tree_text");
1385        fs::create_dir_all(lib.join("a")).unwrap();
1386        fs::write(lib.join("a/x.md"), b"AX").unwrap();
1387        fs::write(lib.join("a/y.md"), b"AY").unwrap();
1388        // `root1` has two children (a profile and a fragment) so both the branch
1389        // (`├──`) and last (`└──`) connectors are exercised; `root2` is a second
1390        // top-level tree so the inter-tree blank line is emitted.
1391        let cfg = cfg_with_lib(
1392            [
1393                ("child", vec!["a/x.md"]),
1394                ("root1", vec!["child", "a/y.md"]),
1395                ("root2", vec!["a/x.md"]),
1396            ],
1397            &lib,
1398            None,
1399        );
1400        let mut out = Vec::new();
1401        show_tree(&cfg, JsonOutput::Text, &mut out).unwrap();
1402        let text = String::from_utf8(out).unwrap();
1403        assert!(text.contains("root1\n"), "text: {text}");
1404        assert!(text.contains("root2\n"), "text: {text}");
1405        assert!(text.contains("├── "), "text: {text}");
1406        assert!(text.contains("└── "), "text: {text}");
1407        assert!(text.contains("child"), "text: {text}");
1408        // A blank line separates the two root trees.
1409        assert!(text.contains("\n\n"), "text: {text}");
1410    }
1411
1412    #[test]
1413    fn show_tree_json_emits_trees_envelope() {
1414        let lib = mk_tmp("prompter_show_tree_json");
1415        fs::create_dir_all(lib.join("a")).unwrap();
1416        fs::write(lib.join("a/x.md"), b"AX").unwrap();
1417        let cfg = cfg_with_lib([("root", vec!["a/x.md"])], &lib, None);
1418        let mut out = Vec::new();
1419        show_tree(&cfg, JsonOutput::Json, &mut out).unwrap();
1420        let envelope: serde_json::Value =
1421            serde_json::from_str(String::from_utf8(out).unwrap().trim()).unwrap();
1422        assert!(envelope["data"]["trees"].is_array());
1423    }
1424
1425    #[test]
1426    fn list_profiles_json_lists_fragments_and_skips_non_md() {
1427        let lib = mk_tmp("prompter_list_json");
1428        fs::create_dir_all(lib.join("a/b")).unwrap();
1429        fs::write(lib.join("a/keep.md"), b"K").unwrap();
1430        fs::write(lib.join("a/b/deep.md"), b"D").unwrap();
1431        fs::write(lib.join("a/notes.txt"), b"ignored").unwrap();
1432        let cfg = cfg_with_lib([("p", vec!["a/keep.md"])], &lib, None);
1433        let mut out = Vec::new();
1434        list_profiles(&cfg, JsonOutput::Json, &mut out).unwrap();
1435        let envelope: serde_json::Value =
1436            serde_json::from_str(String::from_utf8(out).unwrap().trim()).unwrap();
1437        let libraries = envelope["data"]["libraries"].as_array().unwrap();
1438        assert_eq!(libraries.len(), 1);
1439        let fragments = libraries[0]["fragments"].as_array().unwrap();
1440        let names: Vec<&str> = fragments.iter().map(|f| f.as_str().unwrap()).collect();
1441        assert!(names.contains(&"a/keep.md"), "names: {names:?}");
1442        assert!(names.contains(&"a/b/deep.md"), "names: {names:?}");
1443        assert!(!names.contains(&"a/notes.txt"), "names: {names:?}");
1444    }
1445
1446    #[test]
1447    fn validate_accepts_shadowing_family_variant_and_ignores_extras() {
1448        let lib = mk_tmp("prompter_family_valid");
1449        fs::create_dir_all(lib.join("general/families/gpt")).unwrap();
1450        fs::create_dir_all(lib.join("general/sub")).unwrap();
1451        fs::write(lib.join("general/rules.md"), b"NEUTRAL").unwrap();
1452        fs::write(lib.join("general/sub/deep.md"), b"DEEP").unwrap();
1453        // Valid variant that shadows the neutral fragment (no orphan error).
1454        fs::write(lib.join("general/families/gpt/rules.md"), b"GPT").unwrap();
1455        // A non-markdown file inside the family dir is ignored.
1456        fs::write(lib.join("general/families/gpt/notes.txt"), b"x").unwrap();
1457        // A plain file sitting directly under `families/` is not a family dir.
1458        fs::write(lib.join("general/families/README"), b"readme").unwrap();
1459        let cfg = cfg_with_lib(
1460            [("root", vec!["general/rules.md", "general/sub/deep.md"])],
1461            &lib,
1462            None,
1463        );
1464        assert!(validate(&cfg).is_ok());
1465    }
1466
1467    #[test]
1468    fn parse_config_file_rejects_bad_reserved_field_types() {
1469        let cases = [
1470            ("import = [1]\n", "`import`"),
1471            ("library = 5\n", "`library`"),
1472            ("post_prompt = 5\n", "`post_prompt`"),
1473            ("[p]\ndepends_on = [1]\n", "`depends_on`"),
1474        ];
1475        for (input, needle) in cases {
1476            let err = parse_config_file(input).unwrap_err().to_string();
1477            assert!(err.contains(needle), "input={input:?} err={err}");
1478        }
1479    }
1480
1481    #[test]
1482    fn load_config_bundle_missing_primary_is_io_error() {
1483        let dir = mk_tmp("prompter_missing_primary");
1484        fs::create_dir_all(&dir).unwrap();
1485        let err = load_config_bundle(&dir.join("nope.toml"), None).unwrap_err();
1486        assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
1487    }
1488
1489    #[test]
1490    fn load_config_bundle_missing_import_is_io_error() {
1491        let dir = mk_tmp("prompter_missing_import");
1492        fs::create_dir_all(&dir).unwrap();
1493        fs::write(dir.join("config.toml"), "import = [\"./absent.toml\"]\n").unwrap();
1494        let err = load_config_bundle(&dir.join("config.toml"), None).unwrap_err();
1495        assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
1496    }
1497
1498    #[test]
1499    fn parse_args_maps_help_version_and_meta_subcommands() {
1500        // clap's `--help` / `--version` short-circuits map onto the dedicated
1501        // modes rather than being reported as parse errors.
1502        assert!(matches!(
1503            parse_args_from(vec!["prompter".into(), "--help".into()]).unwrap(),
1504            AppMode::Help
1505        ));
1506        assert!(matches!(
1507            parse_args_from(vec!["prompter".into(), "--version".into()]).unwrap(),
1508            AppMode::Version { json: false }
1509        ));
1510
1511        // Each meta subcommand resolves to its corresponding application mode.
1512        assert!(matches!(
1513            parse_args_from(vec!["prompter".into(), "meta".into(), "license".into()]).unwrap(),
1514            AppMode::License
1515        ));
1516        assert!(matches!(
1517            parse_args_from(vec!["prompter".into(), "meta".into(), "doctor".into()]).unwrap(),
1518            AppMode::Doctor { json: false }
1519        ));
1520        assert!(matches!(
1521            parse_args_from(vec![
1522                "prompter".into(),
1523                "meta".into(),
1524                "completions".into(),
1525                "bash".into(),
1526            ])
1527            .unwrap(),
1528            AppMode::Completions { .. }
1529        ));
1530        assert!(matches!(
1531            parse_args_from(vec![
1532                "prompter".into(),
1533                "meta".into(),
1534                "agent".into(),
1535                "list".into(),
1536            ])
1537            .unwrap(),
1538            AppMode::Agent { .. }
1539        ));
1540    }
1541
1542    #[test]
1543    fn resolve_config_path_joins_relative_override_onto_cwd() {
1544        // A relative `-c` override is anchored to the current working directory.
1545        let rel = Path::new("some/relative/config.toml");
1546        let resolved = resolve_config_path(Some(rel)).unwrap();
1547        assert_eq!(resolved, env::current_dir().unwrap().join(rel));
1548        assert!(resolved.is_absolute());
1549    }
1550}