1#![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#[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
70pub 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
93pub const SYSTEM_BASE_PROFILE: &str = "core.base";
98
99#[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
253pub 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#[derive(Debug, Serialize)]
269struct ValidateOutput {
270 valid: bool,
271}
272
273pub 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#[derive(Debug, Serialize)]
306struct FragmentOutput {
307 path: String,
308 content: String,
309}
310
311#[derive(Debug, Serialize)]
313struct RenderOutput {
314 profile: String,
315 pre_prompt: String,
316 system_info: String,
317 fragments: Vec<FragmentOutput>,
318}
319
320pub 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 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 assert_eq!(unescape("a\\xb"), "a\\xb");
385 assert_eq!(unescape("end\\"), "end\\");
387 }
388
389 #[test]
390 fn test_parse_config_file_errors() {
391 let err = parse_config_file("not valid toml {{{")
393 .unwrap_err()
394 .to_string();
395 assert!(err.contains("Invalid TOML"), "err={err}");
396 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 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 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 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 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 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 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 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 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 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 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 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 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 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 }; 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 }; 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 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 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 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 fs::write(lib.join("general/families/gpt/rules.md"), b"GPT").unwrap();
1455 fs::write(lib.join("general/families/gpt/notes.txt"), b"x").unwrap();
1457 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 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 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 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}