1use crate::config::Config;
3use crate::config::load_bundle;
4use crate::{
5 ProfileDef, PrompterError, home_dir, parse_config_file, read_config_with_path, unescape,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::collections::HashSet;
10use std::ffi::OsStr;
11use std::fs;
12use std::io;
13use std::io::Write;
14use std::path::{Component, Path, PathBuf};
15use std::str::FromStr;
16use tftio_lib::{JsonOutput, render_response};
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> {
805 let trees = build_trees(cfg);
806
807 if output.is_json() {
808 let data = serde_json::to_value(&trees)?;
809 writeln!(
810 &mut w,
811 "{}",
812 render_response("tree", JsonOutput::Json, data, String::new())
813 )
814 .map_err(PrompterError::Write)?;
815 } else {
816 for (i, tree) in trees.trees.iter().enumerate() {
817 writeln!(&mut w, "{}", tree.name).map_err(PrompterError::Write)?;
819
820 for (j, child) in tree.children.iter().enumerate() {
822 let is_last = j == tree.children.len() - 1;
823 print_tree(child, "", is_last, &mut w).map_err(PrompterError::Write)?;
824 }
825
826 if i < trees.trees.len() - 1 {
828 writeln!(&mut w).map_err(PrompterError::Write)?;
829 }
830 }
831 }
832
833 Ok(())
834}
835
836pub fn run_tree_stdout(
842 config_override: Option<&Path>,
843 output: JsonOutput,
844) -> Result<(), PrompterError> {
845 let (_cfg_path, cfg) = load_bundle(config_override)?;
846 show_tree(&cfg, output, io::stdout())
847}
848
849#[cfg(test)]
850#[allow(clippy::wildcard_imports)]
851mod tests {
852 use super::*;
853 use std::sync::atomic::{AtomicU32, Ordering};
854
855 static COUNTER: AtomicU32 = AtomicU32::new(0);
856
857 fn mk_tmp(prefix: &str) -> PathBuf {
858 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
859 std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
860 }
861
862 fn cfg_from_roots(profiles: Vec<(&str, Vec<&str>, PathBuf)>) -> Config {
864 let map = profiles
865 .into_iter()
866 .map(|(name, deps, root)| {
867 (
868 name.to_string(),
869 ProfileDef {
870 deps: deps.into_iter().map(String::from).collect(),
871 library_root: root,
872 },
873 )
874 })
875 .collect();
876 Config {
877 profiles: map,
878 post_prompt: None,
879 }
880 }
881
882 #[test]
883 fn family_name_display_matches_as_str() {
884 let family = FamilyName::new("gpt").unwrap();
885 assert_eq!(family.as_str(), "gpt");
886 assert_eq!(format!("{family}"), "gpt");
888 assert_eq!(family.to_string(), "gpt");
889 }
890
891 #[test]
892 fn parse_config_file_ignores_non_table_top_level_values() {
893 let raw = parse_config_file("stray = 1\n").unwrap();
895 assert!(raw.profiles.is_empty());
896 }
897
898 #[test]
899 fn parse_config_file_rejects_duplicate_flattened_profile() {
900 let input = "[\"x.y\"]\ndepends_on = []\n\n[x.y]\ndepends_on = []\n";
903 let err = parse_config_file(input).unwrap_err();
904 assert!(
905 matches!(err, PrompterError::DuplicateProfile(_)),
906 "err={err}"
907 );
908 }
909
910 #[test]
911 fn parse_config_file_flattens_empty_root_table_name() {
912 let input = "[\"\"]\ndepends_on = []\n\n[\"\".child]\ndepends_on = []\n";
915 let raw = parse_config_file(input).unwrap();
916 assert!(raw.profiles.contains_key(""), "profiles={:?}", raw.profiles);
917 assert!(
918 raw.profiles.contains_key("child"),
919 "profiles={:?}",
920 raw.profiles
921 );
922 }
923
924 #[test]
925 fn library_root_for_uses_absolute_library_verbatim() {
926 let cfg_path = mk_tmp("prompter_libroot").join("config.toml");
927 let root = library_root_for(&cfg_path, Some("/abs/lib"), None).unwrap();
928 assert_eq!(root, PathBuf::from("/abs/lib"));
929 assert!(root.is_absolute());
930 }
931
932 #[test]
933 fn list_profiles_json_skips_absent_library_root() {
934 let absent = mk_tmp("prompter_absent_root");
937 let cfg = cfg_from_roots(vec![("p", vec![], absent)]);
938 let mut out = Vec::new();
939 list_profiles(&cfg, JsonOutput::Json, &mut out).unwrap();
940 let envelope: serde_json::Value =
941 serde_json::from_str(std::str::from_utf8(&out).unwrap().trim()).unwrap();
942 let libraries = envelope["data"]["libraries"].as_array().unwrap();
943 assert_eq!(libraries.len(), 1);
944 assert!(libraries[0]["fragments"].as_array().unwrap().is_empty());
945 }
946
947 #[test]
948 fn list_profiles_json_errors_when_library_root_is_a_file() {
949 let dir = mk_tmp("prompter_root_is_file");
952 fs::create_dir_all(&dir).unwrap();
953 let file_root = dir.join("not_a_dir");
954 fs::write(&file_root, b"x").unwrap();
955 let cfg = cfg_from_roots(vec![("p", vec![], file_root)]);
956 let mut out = Vec::new();
957 let err = list_profiles(&cfg, JsonOutput::Json, &mut out).unwrap_err();
958 assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
959 fs::remove_dir_all(&dir).ok();
960 }
961
962 #[test]
963 fn validate_errors_when_library_root_is_unreadable() {
964 let absent = mk_tmp("prompter_validate_absent_root");
967 let cfg = cfg_from_roots(vec![("p", vec![], absent)]);
968 let err = validate(&cfg).unwrap_err();
969 assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
970 }
971}