1use super::config::Config;
3use super::config::load_bundle;
4use super::{
5 ProfileDef, PrompterError, home_dir, parse_config_file, read_config_with_path, unescape,
6};
7use crate::{JsonOutput, render_response};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::collections::HashSet;
11use std::ffi::OsStr;
12use std::fs;
13use std::io;
14use std::io::Write;
15use std::path::{Component, Path, PathBuf};
16use std::str::FromStr;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct FamilyName(String);
24
25impl FamilyName {
26 pub fn new(value: impl Into<String>) -> Result<Self, InvalidFamilyName> {
32 let value = value.into();
33 let mut components = Path::new(&value).components();
34 let is_single_component = matches!(
35 components.next(),
36 Some(Component::Normal(component)) if component == OsStr::new(&value)
37 ) && components.next().is_none();
38
39 if is_single_component {
40 Ok(Self(value))
41 } else {
42 Err(InvalidFamilyName(value))
43 }
44 }
45
46 #[must_use]
48 pub fn as_str(&self) -> &str {
49 &self.0
50 }
51}
52
53impl FromStr for FamilyName {
54 type Err = InvalidFamilyName;
55
56 fn from_str(value: &str) -> Result<Self, Self::Err> {
57 Self::new(value)
58 }
59}
60
61impl std::fmt::Display for FamilyName {
62 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 formatter.write_str(self.as_str())
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69#[error("family name must be one non-empty path component: {0}")]
70pub struct InvalidFamilyName(String);
71
72pub(crate) fn collect_profiles(
73 prefix: &str,
74 value: &toml::Value,
75 out: &mut HashMap<String, Vec<String>>,
76) -> Result<(), PrompterError> {
77 let Some(table) = value.as_table() else {
78 return Ok(());
79 };
80
81 if let Some(deps_value) = table.get("depends_on") {
82 let arr = deps_value.as_array().ok_or_else(|| {
83 PrompterError::ConfigField(format!(
84 "`depends_on` for [{prefix}] must be an array of strings"
85 ))
86 })?;
87 let mut deps = Vec::with_capacity(arr.len());
88 for entry in arr {
89 let s = entry.as_str().ok_or_else(|| {
90 PrompterError::ConfigField(format!(
91 "`depends_on` entries for [{prefix}] must be strings"
92 ))
93 })?;
94 deps.push(s.to_string());
95 }
96 if out.insert(prefix.to_string(), deps).is_some() {
97 return Err(PrompterError::DuplicateProfile(format!(
98 "Duplicate profile definition: [{prefix}]"
99 )));
100 }
101 }
102
103 for (sub_key, sub_val) in table {
104 if sub_key == "depends_on" {
105 continue;
106 }
107 let new_prefix = if prefix.is_empty() {
108 sub_key.clone()
109 } else {
110 format!("{prefix}.{sub_key}")
111 };
112 collect_profiles(&new_prefix, sub_val, out)?;
113 }
114 Ok(())
115}
116
117pub(crate) fn expand_tilde(s: &str) -> Result<PathBuf, PrompterError> {
121 if let Some(rest) = s.strip_prefix("~/") {
122 Ok(home_dir()?.join(rest))
123 } else if s == "~" {
124 home_dir()
125 } else {
126 Ok(PathBuf::from(s))
127 }
128}
129
130fn resolve_import_path(importer_dir: &Path, import_str: &str) -> Result<PathBuf, PrompterError> {
135 let p = expand_tilde(import_str)?;
136 if p.is_absolute() {
137 Ok(p)
138 } else {
139 Ok(importer_dir.join(p))
140 }
141}
142
143fn library_root_for(
151 config_path: &Path,
152 raw_library: Option<&str>,
153 default_library: Option<&Path>,
154) -> Result<PathBuf, PrompterError> {
155 let config_dir = config_path
156 .parent()
157 .ok_or_else(|| PrompterError::NoParentDir(config_path.to_path_buf()))?;
158
159 if let Some(lib_str) = raw_library {
160 let p = expand_tilde(lib_str)?;
161 return Ok(if p.is_absolute() {
162 p
163 } else {
164 config_dir.join(p)
165 });
166 }
167
168 if let Some(default) = default_library {
169 return Ok(default.to_path_buf());
170 }
171
172 Ok(config_dir.join("library"))
173}
174
175pub fn load_config_bundle(
192 primary_path: &Path,
193 primary_default_library: Option<&Path>,
194) -> Result<Config, PrompterError> {
195 let mut profiles: HashMap<String, ProfileDef> = HashMap::new();
196 let mut visited: HashSet<PathBuf> = HashSet::new();
197 let mut post_prompt: Option<String> = None;
198
199 load_one(
200 primary_path,
201 primary_default_library,
202 true,
203 &mut profiles,
204 &mut visited,
205 &mut post_prompt,
206 )?;
207
208 Ok(Config {
209 profiles,
210 post_prompt,
211 })
212}
213
214fn load_one(
215 path: &Path,
216 default_library: Option<&Path>,
217 is_primary: bool,
218 profiles: &mut HashMap<String, ProfileDef>,
219 visited: &mut HashSet<PathBuf>,
220 post_prompt: &mut Option<String>,
221) -> Result<(), PrompterError> {
222 let text = read_config_with_path(path)?;
223 let canonical = fs::canonicalize(path).map_err(|source| PrompterError::Io {
224 path: path.to_path_buf(),
225 source,
226 })?;
227
228 if !visited.insert(canonical.clone()) {
229 return Err(PrompterError::ImportCycle(canonical));
230 }
231
232 let raw = parse_config_file(&text)?;
233
234 let library_root = library_root_for(&canonical, raw.library.as_deref(), default_library)?;
235
236 for (name, deps) in raw.profiles {
237 if let Some(existing) = profiles.get(&name) {
238 return Err(PrompterError::DuplicateProfile(format!(
239 "Duplicate profile `{}` defined in both {} and {}",
240 name,
241 existing.library_root.display(),
242 library_root.display()
243 )));
244 }
245 profiles.insert(
246 name,
247 ProfileDef {
248 deps,
249 library_root: library_root.clone(),
250 },
251 );
252 }
253
254 if is_primary {
255 *post_prompt = raw.post_prompt.map(|s| unescape(&s));
256 }
257
258 let importer_dir = canonical
259 .parent()
260 .ok_or_else(|| PrompterError::NoParentDir(canonical.clone()))?
261 .to_path_buf();
262 for import_str in raw.imports {
263 let import_path = resolve_import_path(&importer_dir, &import_str)?;
264 load_one(&import_path, None, false, profiles, visited, post_prompt)?;
267 }
268
269 Ok(())
270}
271
272#[derive(thiserror::Error, Debug, PartialEq, Eq)]
277pub enum ResolveError {
278 #[error("Unknown profile: {0}")]
280 UnknownProfile(String),
281 #[error("Cycle detected: {}", .0.join(" -> "))]
283 Cycle(Vec<String>),
284 #[error("Missing file: {path} (referenced by [{referenced_by}])", path = .0.display(), referenced_by = .1)]
286 MissingFile(PathBuf, String), }
288
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
291#[serde(rename_all = "lowercase")]
292pub enum TreeNodeType {
293 Profile,
295 Fragment,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct TreeNode {
302 #[serde(rename = "type")]
304 pub node_type: TreeNodeType,
305 pub name: String,
307 #[serde(skip_serializing_if = "Vec::is_empty")]
309 pub children: Vec<Self>,
310}
311
312#[derive(Debug, Serialize, Deserialize)]
314pub struct TreeOutput {
315 pub trees: Vec<TreeNode>,
317}
318
319#[allow(
335 clippy::implicit_hasher,
336 reason = "public resolver API intentionally fixes the std default HashMap hasher rather than exposing a hasher type parameter"
337)]
338pub fn resolve_profile(
339 name: &str,
340 cfg: &Config,
341 seen_files: &mut HashSet<PathBuf>,
342 stack: &mut Vec<String>,
343 out: &mut Vec<(PathBuf, PathBuf)>,
344) -> Result<(), ResolveError> {
345 resolve_profile_for_family(name, cfg, None, seen_files, stack, out)
346}
347
348pub(crate) fn resolve_profile_for_family(
349 name: &str,
350 cfg: &Config,
351 family: Option<&FamilyName>,
352 seen_files: &mut HashSet<PathBuf>,
353 stack: &mut Vec<String>,
354 out: &mut Vec<(PathBuf, PathBuf)>,
355) -> Result<(), ResolveError> {
356 if stack.contains(&name.to_string()) {
357 let mut cycle = stack.clone();
358 cycle.push(name.to_string());
359 return Err(ResolveError::Cycle(cycle));
360 }
361 let profile = cfg
362 .profiles
363 .get(name)
364 .ok_or_else(|| ResolveError::UnknownProfile(name.to_string()))?;
365 stack.push(name.to_string());
366 for dep in &profile.deps {
367 if std::path::Path::new(dep)
368 .extension()
369 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
370 {
371 let path = profile.library_root.join(dep);
372 if !path.exists() {
373 return Err(ResolveError::MissingFile(path, name.to_string()));
374 }
375 if seen_files.insert(path.clone()) {
376 let rendered_path = family.map_or_else(
377 || path.clone(),
378 |family| {
379 let variant = path.parent().map_or_else(
380 || path.clone(),
381 |parent| {
382 parent
383 .join("families")
384 .join(family.as_str())
385 .join(path.file_name().unwrap_or_default())
386 },
387 );
388 if variant.is_file() {
389 variant
390 } else {
391 path.clone()
392 }
393 },
394 );
395 out.push((rendered_path, profile.library_root.clone()));
396 }
397 } else {
398 resolve_profile_for_family(dep, cfg, family, seen_files, stack, out)?;
399 }
400 }
401 stack.pop();
402 Ok(())
403}
404
405#[derive(Debug, Serialize)]
407struct ListOutput {
408 profiles: Vec<ProfileInfo>,
409 libraries: Vec<LibraryOutput>,
410}
411
412#[derive(Debug, Serialize)]
414struct ProfileInfo {
415 name: String,
416 dependencies: Vec<String>,
417 library_root: String,
418}
419
420#[derive(Debug, Serialize)]
422struct LibraryOutput {
423 path: String,
424 fragments: Vec<String>,
425}
426
427pub fn list_profiles(
439 cfg: &Config,
440 output: JsonOutput,
441 mut w: impl Write,
442) -> Result<(), PrompterError> {
443 if output.is_json() {
444 let mut unique_roots: Vec<PathBuf> = Vec::new();
446 for profile in cfg.profiles.values() {
447 if !unique_roots.contains(&profile.library_root) {
448 unique_roots.push(profile.library_root.clone());
449 }
450 }
451 unique_roots.sort();
452
453 let mut libraries = Vec::with_capacity(unique_roots.len());
454 for root in &unique_roots {
455 let mut fragments = Vec::new();
456 if root.exists() {
457 collect_fragments(root, root, &mut fragments)?;
458 }
459 fragments.sort();
460 libraries.push(LibraryOutput {
461 path: root.display().to_string(),
462 fragments,
463 });
464 }
465
466 let mut profiles: Vec<ProfileInfo> = cfg
467 .profiles
468 .iter()
469 .map(|(name, def)| ProfileInfo {
470 name: name.clone(),
471 dependencies: def.deps.clone(),
472 library_root: def.library_root.display().to_string(),
473 })
474 .collect();
475 profiles.sort_by(|a, b| a.name.cmp(&b.name));
476
477 let data = serde_json::to_value(ListOutput {
478 profiles,
479 libraries,
480 })?;
481 writeln!(
482 &mut w,
483 "{}",
484 render_response("list", JsonOutput::Json, data, String::new())
485 )
486 .map_err(PrompterError::Write)?;
487 } else {
488 let mut names: Vec<_> = cfg.profiles.keys().cloned().collect();
489 names.sort();
490 for n in names {
491 writeln!(&mut w, "{n}").map_err(PrompterError::Write)?;
492 }
493 }
494 Ok(())
495}
496
497fn collect_fragments(
499 root: &Path,
500 dir: &Path,
501 fragments: &mut Vec<String>,
502) -> Result<(), PrompterError> {
503 let entries = fs::read_dir(dir).map_err(|source| PrompterError::Io {
504 path: dir.to_path_buf(),
505 source,
506 })?;
507
508 for entry in entries {
509 let entry = entry.map_err(|source| PrompterError::Io {
510 path: dir.to_path_buf(),
511 source,
512 })?;
513 let path = entry.path();
514
515 if path.is_dir() {
516 collect_fragments(root, &path, fragments)?;
517 } else if path
518 .extension()
519 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
520 && let Ok(rel_path) = path.strip_prefix(root)
521 {
522 fragments.push(rel_path.display().to_string());
523 }
524 }
525
526 Ok(())
527}
528
529fn inspect_family_variants(
530 directory: &Path,
531 families: &mut HashSet<FamilyName>,
532 errors: &mut Vec<String>,
533) -> Result<(), PrompterError> {
534 let entries = fs::read_dir(directory).map_err(|source| PrompterError::Io {
535 path: directory.to_path_buf(),
536 source,
537 })?;
538
539 for entry in entries {
540 let entry = entry.map_err(|source| PrompterError::Io {
541 path: directory.to_path_buf(),
542 source,
543 })?;
544 let path = entry.path();
545 let file_type = entry.file_type().map_err(|source| PrompterError::Io {
546 path: path.clone(),
547 source,
548 })?;
549 if !file_type.is_dir() {
550 continue;
551 }
552
553 if entry.file_name() == OsStr::new("families") {
554 inspect_families_directory(directory, &path, families, errors)?;
555 } else {
556 inspect_family_variants(&path, families, errors)?;
557 }
558 }
559
560 Ok(())
561}
562
563fn inspect_families_directory(
564 fragment_directory: &Path,
565 families_directory: &Path,
566 families: &mut HashSet<FamilyName>,
567 errors: &mut Vec<String>,
568) -> Result<(), PrompterError> {
569 let entries = fs::read_dir(families_directory).map_err(|source| PrompterError::Io {
570 path: families_directory.to_path_buf(),
571 source,
572 })?;
573
574 for entry in entries {
575 let entry = entry.map_err(|source| PrompterError::Io {
576 path: families_directory.to_path_buf(),
577 source,
578 })?;
579 let family_directory = entry.path();
580 let file_type = entry.file_type().map_err(|source| PrompterError::Io {
581 path: family_directory.clone(),
582 source,
583 })?;
584 if !file_type.is_dir() {
585 continue;
586 }
587
588 let family_os = entry.file_name();
589 let Some(family_text) = family_os.to_str() else {
590 errors.push(format!(
591 "Invalid non-UTF-8 family directory: {}",
592 family_directory.display()
593 ));
594 continue;
595 };
596 let family = match FamilyName::new(family_text) {
597 Ok(family) => family,
598 Err(error) => {
599 errors.push(format!("{error}: {}", family_directory.display()));
600 continue;
601 }
602 };
603 families.insert(family);
604
605 let variants = fs::read_dir(&family_directory).map_err(|source| PrompterError::Io {
606 path: family_directory.clone(),
607 source,
608 })?;
609 for variant in variants {
610 let variant = variant.map_err(|source| PrompterError::Io {
611 path: family_directory.clone(),
612 source,
613 })?;
614 let variant_path = variant.path();
615 let variant_type = variant.file_type().map_err(|source| PrompterError::Io {
616 path: variant_path.clone(),
617 source,
618 })?;
619 if !variant_type.is_file()
620 || !variant_path
621 .extension()
622 .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
623 {
624 continue;
625 }
626
627 let neutral_path = fragment_directory.join(variant.file_name());
628 if !neutral_path.is_file() {
629 errors.push(format!(
630 "Orphan family variant: {} does not shadow neutral fragment {}",
631 variant_path.display(),
632 neutral_path.display()
633 ));
634 }
635 }
636 }
637
638 Ok(())
639}
640
641pub fn validate(cfg: &Config) -> Result<(), PrompterError> {
654 let mut errors: Vec<String> = Vec::new();
655
656 for (profile_name, profile) in &cfg.profiles {
657 for dep in &profile.deps {
658 if std::path::Path::new(dep)
659 .extension()
660 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
661 {
662 let path = profile.library_root.join(dep);
663 if !path.exists() {
664 errors.push(format!(
665 "Missing file: {} (referenced by [{}])",
666 path.display(),
667 profile_name
668 ));
669 }
670 } else if !cfg.profiles.contains_key(dep) {
671 errors.push(format!(
672 "Unknown profile: {dep} (referenced by [{profile_name}])"
673 ));
674 }
675 }
676 }
677
678 let mut family_set = HashSet::new();
679 for library_root in cfg.library_roots() {
680 inspect_family_variants(&library_root, &mut family_set, &mut errors)?;
681 }
682 let mut families: Vec<_> = family_set.into_iter().collect();
683 families.sort();
684
685 for name in cfg.profiles.keys() {
686 for family in std::iter::once(None).chain(families.iter().map(Some)) {
687 let mut seen_files = HashSet::new();
688 let mut stack = Vec::new();
689 let mut out = Vec::new();
690 if let Err(error) =
691 resolve_profile_for_family(name, cfg, family, &mut seen_files, &mut stack, &mut out)
692 {
693 let message = error.to_string();
694 if !errors.contains(&message) {
695 errors.push(message);
696 }
697 }
698 }
699 }
700
701 if errors.is_empty() {
702 Ok(())
703 } else {
704 Err(PrompterError::Validation(errors.join("\n")))
705 }
706}
707
708fn build_tree_node(name: &str, cfg: &Config) -> TreeNode {
710 if std::path::Path::new(name)
712 .extension()
713 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
714 {
715 return TreeNode {
716 node_type: TreeNodeType::Fragment,
717 name: name.to_string(),
718 children: Vec::new(),
719 };
720 }
721
722 let children = cfg
724 .profiles
725 .get(name)
726 .map(|def| {
727 def.deps
728 .iter()
729 .map(|dep| build_tree_node(dep, cfg))
730 .collect()
731 })
732 .unwrap_or_default();
733
734 TreeNode {
735 node_type: TreeNodeType::Profile,
736 name: name.to_string(),
737 children,
738 }
739}
740
741fn find_root_profiles(cfg: &Config) -> Vec<String> {
743 let mut referenced = HashSet::new();
744
745 for profile in cfg.profiles.values() {
747 for dep in &profile.deps {
748 if !std::path::Path::new(dep)
750 .extension()
751 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
752 {
753 referenced.insert(dep.clone());
754 }
755 }
756 }
757
758 let mut roots: Vec<String> = cfg
760 .profiles
761 .keys()
762 .filter(|profile| !referenced.contains(*profile))
763 .cloned()
764 .collect();
765
766 roots.sort();
767 roots
768}
769
770fn build_trees(cfg: &Config) -> TreeOutput {
772 let root_profiles = find_root_profiles(cfg);
773 let trees = root_profiles
774 .iter()
775 .map(|profile| build_tree_node(profile, cfg))
776 .collect();
777
778 TreeOutput { trees }
779}
780
781fn print_tree(node: &TreeNode, prefix: &str, is_last: bool, w: &mut impl Write) -> io::Result<()> {
783 let connector = if is_last { "└── " } else { "├── " };
785 writeln!(w, "{prefix}{connector}{}", node.name)?;
786
787 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
789
790 for (i, child) in node.children.iter().enumerate() {
792 let is_last_child = i == node.children.len() - 1;
793 print_tree(child, &child_prefix, is_last_child, w)?;
794 }
795
796 Ok(())
797}
798
799pub fn show_tree(cfg: &Config, output: JsonOutput, mut w: impl Write) -> Result<(), PrompterError> {
806 let trees = build_trees(cfg);
807
808 if output.is_json() {
809 let data = serde_json::to_value(&trees)?;
810 writeln!(
811 &mut w,
812 "{}",
813 render_response("tree", JsonOutput::Json, data, String::new())
814 )
815 .map_err(PrompterError::Write)?;
816 } else {
817 for (i, tree) in trees.trees.iter().enumerate() {
818 writeln!(&mut w, "{}", tree.name).map_err(PrompterError::Write)?;
820
821 for (j, child) in tree.children.iter().enumerate() {
823 let is_last = j == tree.children.len() - 1;
824 print_tree(child, "", is_last, &mut w).map_err(PrompterError::Write)?;
825 }
826
827 if i < trees.trees.len() - 1 {
829 writeln!(&mut w).map_err(PrompterError::Write)?;
830 }
831 }
832 }
833
834 Ok(())
835}
836
837pub fn run_tree_stdout(
844 config_override: Option<&Path>,
845 output: JsonOutput,
846) -> Result<(), PrompterError> {
847 let (_cfg_path, cfg) = load_bundle(config_override)?;
848 show_tree(&cfg, output, io::stdout())
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854
855 fn unique_temp_path(label: &str) -> PathBuf {
858 use std::sync::atomic::{AtomicU32, Ordering};
859 static COUNTER: AtomicU32 = AtomicU32::new(0);
860 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
861 std::env::temp_dir().join(format!(
862 "tftio-lib-profile-{label}-{}-{n}",
863 std::process::id()
864 ))
865 }
866
867 fn cfg_with_lib<I>(profiles: I, lib: &Path) -> Config
869 where
870 I: IntoIterator<Item = (&'static str, Vec<&'static str>)>,
871 {
872 let profiles = profiles
873 .into_iter()
874 .map(|(name, deps)| {
875 (
876 name.to_string(),
877 ProfileDef {
878 deps: deps.into_iter().map(String::from).collect(),
879 library_root: lib.to_path_buf(),
880 },
881 )
882 })
883 .collect();
884 Config {
885 profiles,
886 post_prompt: None,
887 }
888 }
889
890 #[test]
891 fn family_name_display_renders_inner_value() {
892 let family = FamilyName::new("gpt").unwrap();
893 assert_eq!(family.to_string(), "gpt");
894 assert_eq!(format!("{family}"), "gpt");
895 }
896
897 #[test]
898 fn collect_profiles_ignores_non_table_value() {
899 let mut out: HashMap<String, Vec<String>> = HashMap::new();
900 collect_profiles("scalar", &toml::Value::Integer(5), &mut out).unwrap();
901 assert!(out.is_empty());
902 }
903
904 #[test]
905 fn collect_profiles_rejects_non_string_depends_on_entries() {
906 let value: toml::Value = toml::from_str("depends_on = [1]").unwrap();
907 let mut out: HashMap<String, Vec<String>> = HashMap::new();
908 let err = collect_profiles("p", &value, &mut out).unwrap_err();
909 assert!(matches!(err, PrompterError::ConfigField(_)));
910 assert!(err.to_string().contains("must be strings"), "err={err}");
911 }
912
913 #[test]
914 fn collect_profiles_detects_duplicate_prefix() {
915 let value: toml::Value = toml::from_str("depends_on = [\"a.md\"]").unwrap();
916 let mut out: HashMap<String, Vec<String>> = HashMap::new();
917 out.insert("p".to_string(), vec!["preexisting".to_string()]);
918 let err = collect_profiles("p", &value, &mut out).unwrap_err();
919 assert!(matches!(err, PrompterError::DuplicateProfile(_)));
920 assert!(err.to_string().contains("Duplicate profile"), "err={err}");
921 }
922
923 #[test]
924 fn collect_profiles_handles_empty_prefix() {
925 let value: toml::Value = toml::from_str("[x]\ndepends_on = [\"a.md\"]").unwrap();
928 let mut out: HashMap<String, Vec<String>> = HashMap::new();
929 collect_profiles("", &value, &mut out).unwrap();
930 assert_eq!(out.get("x").map(Vec::len), Some(1));
931 assert_eq!(
932 out.get("x").map(Vec::as_slice),
933 Some(["a.md".to_string()].as_slice())
934 );
935 }
936
937 #[test]
938 fn load_config_bundle_resolves_relative_import() {
939 let dir = unique_temp_path("relimport");
940 fs::create_dir_all(&dir).unwrap();
941 fs::write(dir.join("child.toml"), "[imported]\ndepends_on = []\n").unwrap();
942 fs::write(
943 dir.join("config.toml"),
944 "import = [\"child.toml\"]\n\n[main]\ndepends_on = []\n",
945 )
946 .unwrap();
947
948 let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
949 assert!(cfg.profiles.contains_key("main"));
950 assert!(cfg.profiles.contains_key("imported"));
951
952 fs::remove_dir_all(&dir).ok();
953 }
954
955 #[test]
956 fn load_config_bundle_honors_absolute_library_key() {
957 let dir = unique_temp_path("abslib-cfg");
958 fs::create_dir_all(&dir).unwrap();
959 let abs_lib = unique_temp_path("abslib-target");
960 fs::create_dir_all(&abs_lib).unwrap();
961 assert!(abs_lib.is_absolute());
962
963 let config_text = format!(
964 "library = \"{}\"\n\n[p]\ndepends_on = []\n",
965 abs_lib.display()
966 );
967 fs::write(dir.join("config.toml"), config_text).unwrap();
968
969 let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
970 assert_eq!(cfg.profiles.get("p").unwrap().library_root, abs_lib);
971
972 fs::remove_dir_all(&dir).ok();
973 fs::remove_dir_all(&abs_lib).ok();
974 }
975
976 #[test]
977 fn list_profiles_json_emits_sorted_libraries_and_fragments() {
978 let lib = unique_temp_path("list-json");
979 fs::create_dir_all(lib.join("sub")).unwrap();
980 fs::write(lib.join("top.md"), b"T").unwrap();
981 fs::write(lib.join("sub/nested.md"), b"N").unwrap();
982 fs::write(lib.join("notes.txt"), b"ignore").unwrap();
984
985 let cfg = cfg_with_lib(
988 [("beta", vec!["top.md"]), ("alpha", vec!["sub/nested.md"])],
989 &lib,
990 );
991
992 let mut out = Vec::new();
993 list_profiles(&cfg, JsonOutput::Json, &mut out).unwrap();
994 let text = String::from_utf8(out).unwrap();
995 let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
996
997 assert_eq!(value["ok"], serde_json::json!(true));
998 assert_eq!(value["command"], serde_json::json!("list"));
999
1000 let profiles = value["data"]["profiles"].as_array().unwrap();
1001 assert_eq!(profiles.len(), 2);
1002 assert_eq!(profiles[0]["name"], serde_json::json!("alpha"));
1003 assert_eq!(profiles[1]["name"], serde_json::json!("beta"));
1004
1005 let libraries = value["data"]["libraries"].as_array().unwrap();
1006 assert_eq!(libraries.len(), 1);
1007 assert_eq!(
1008 libraries[0]["path"],
1009 serde_json::json!(lib.display().to_string())
1010 );
1011 let fragments: Vec<&str> = libraries[0]["fragments"]
1012 .as_array()
1013 .unwrap()
1014 .iter()
1015 .map(|f| f.as_str().unwrap())
1016 .collect();
1017 assert_eq!(fragments, vec!["sub/nested.md", "top.md"]);
1018
1019 fs::remove_dir_all(&lib).ok();
1020 }
1021
1022 #[test]
1023 fn collect_fragments_errors_on_missing_directory() {
1024 let missing = unique_temp_path("collect-missing");
1025 let mut fragments = Vec::new();
1026 let err = collect_fragments(&missing, &missing, &mut fragments).unwrap_err();
1027 match err {
1028 PrompterError::Io { path, .. } => assert_eq!(path, missing),
1029 other => panic!("expected Io error, got {other:?}"),
1030 }
1031 }
1032
1033 #[test]
1034 fn inspect_family_variants_errors_on_missing_directory() {
1035 let missing = unique_temp_path("inspect-missing");
1036 let mut families = HashSet::new();
1037 let mut errors = Vec::new();
1038 let err = inspect_family_variants(&missing, &mut families, &mut errors).unwrap_err();
1039 match err {
1040 PrompterError::Io { path, .. } => assert_eq!(path, missing),
1041 other => panic!("expected Io error, got {other:?}"),
1042 }
1043 }
1044
1045 #[test]
1046 fn inspect_families_directory_errors_on_missing_directory() {
1047 let fragment_dir = unique_temp_path("families-frag");
1048 let missing_families = fragment_dir.join("families");
1049 let mut families = HashSet::new();
1050 let mut errors = Vec::new();
1051 let err = inspect_families_directory(
1052 &fragment_dir,
1053 &missing_families,
1054 &mut families,
1055 &mut errors,
1056 )
1057 .unwrap_err();
1058 match err {
1059 PrompterError::Io { path, .. } => assert_eq!(path, missing_families),
1060 other => panic!("expected Io error, got {other:?}"),
1061 }
1062 }
1063
1064 #[test]
1065 fn validate_skips_non_directory_family_entries() {
1066 let lib = unique_temp_path("family-skips");
1067 fs::create_dir_all(lib.join("general/families/gpt")).unwrap();
1068 fs::write(lib.join("general/rules.md"), b"NEUTRAL").unwrap();
1069 fs::write(lib.join("general/families/gpt/rules.md"), b"GPT").unwrap();
1071 fs::write(lib.join("general/families/README.txt"), b"not a dir").unwrap();
1073 fs::write(lib.join("general/families/gpt/notes.txt"), b"not md").unwrap();
1075
1076 let cfg = cfg_with_lib([("root", vec!["general/rules.md"])], &lib);
1077 assert!(validate(&cfg).is_ok());
1078
1079 fs::remove_dir_all(&lib).ok();
1080 }
1081
1082 #[test]
1083 fn show_tree_text_renders_deterministic_tree() {
1084 let lib = unique_temp_path("tree-text");
1087 let cfg = cfg_with_lib(
1088 [
1089 ("alpha", vec!["beta", "x.md", "phantom"]),
1090 ("beta", vec!["y.md", "z.md"]),
1091 ("gamma", vec!["w.md"]),
1092 ],
1093 &lib,
1094 );
1095
1096 let mut out = Vec::new();
1097 show_tree(&cfg, JsonOutput::Text, &mut out).unwrap();
1098 let text = String::from_utf8(out).unwrap();
1099
1100 let expected = concat!(
1103 "alpha\n",
1104 "├── beta\n",
1105 "│ ├── y.md\n",
1106 "│ └── z.md\n",
1107 "├── x.md\n",
1108 "└── phantom\n",
1109 "\n",
1110 "gamma\n",
1111 "└── w.md\n",
1112 );
1113 assert_eq!(text, expected);
1114 }
1115
1116 #[test]
1117 fn show_tree_json_serializes_trees() {
1118 let lib = unique_temp_path("tree-json");
1119 let cfg = cfg_with_lib(
1120 [("alpha", vec!["beta", "x.md"]), ("beta", vec!["y.md"])],
1121 &lib,
1122 );
1123
1124 let mut out = Vec::new();
1125 show_tree(&cfg, JsonOutput::Json, &mut out).unwrap();
1126 let text = String::from_utf8(out).unwrap();
1127 let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1128
1129 assert_eq!(value["ok"], serde_json::json!(true));
1130 assert_eq!(value["command"], serde_json::json!("tree"));
1131
1132 let trees = value["data"]["trees"].as_array().unwrap();
1133 assert_eq!(trees.len(), 1);
1134 assert_eq!(trees[0]["name"], serde_json::json!("alpha"));
1135 assert_eq!(trees[0]["type"], serde_json::json!("profile"));
1136
1137 let children = trees[0]["children"].as_array().unwrap();
1138 assert_eq!(children[0]["name"], serde_json::json!("beta"));
1139 assert_eq!(children[0]["type"], serde_json::json!("profile"));
1140 assert_eq!(
1141 children[0]["children"][0]["name"],
1142 serde_json::json!("y.md")
1143 );
1144 assert_eq!(
1145 children[0]["children"][0]["type"],
1146 serde_json::json!("fragment")
1147 );
1148 assert_eq!(children[1]["name"], serde_json::json!("x.md"));
1149 assert_eq!(children[1]["type"], serde_json::json!("fragment"));
1150 assert!(children[1].get("children").is_none());
1152 }
1153
1154 #[test]
1155 fn run_tree_stdout_with_explicit_config_succeeds() {
1156 let root = unique_temp_path("tree-stdout");
1157 fs::create_dir_all(root.join("library/a")).unwrap();
1158 fs::write(root.join("library/a/x.md"), b"AX").unwrap();
1159 let config = root.join("config.toml");
1160 fs::write(
1161 &config,
1162 "[child]\ndepends_on = [\"a/x.md\"]\n\n[root]\ndepends_on = [\"child\"]\n",
1163 )
1164 .unwrap();
1165
1166 assert!(run_tree_stdout(Some(&config), JsonOutput::Text).is_ok());
1167 assert!(run_tree_stdout(Some(&config), JsonOutput::Json).is_ok());
1168
1169 fs::remove_dir_all(&root).ok();
1170 }
1171}