Skip to main content

prompter/
lib.rs

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