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
15pub 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#[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
91pub 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
114pub const SYSTEM_BASE_PROFILE: &str = "core.base";
119
120#[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
274pub 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#[derive(Debug, Serialize)]
288struct ValidateOutput {
289 valid: bool,
290}
291
292pub 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#[derive(Debug, Serialize)]
325struct FragmentOutput {
326 path: String,
327 content: String,
328}
329
330#[derive(Debug, Serialize)]
332struct RenderOutput {
333 profile: String,
334 pre_prompt: String,
335 system_info: String,
336 fragments: Vec<FragmentOutput>,
337}
338
339pub 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 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 let err = parse_config_file("not valid toml {{{")
418 .unwrap_err()
419 .to_string();
420 assert!(err.contains("Invalid TOML"), "err={err}");
421 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 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 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 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 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 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 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 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 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 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 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 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 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 }; 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 }; 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 let result = render_to_vec(&[], None, None);
1419 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 }
1433}