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_cli_common::{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<TreeNode>,
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 {
521 if let Ok(rel_path) = path.strip_prefix(root) {
522 fragments.push(rel_path.display().to_string());
523 }
524 }
525 }
526
527 Ok(())
528}
529
530fn inspect_family_variants(
531 directory: &Path,
532 families: &mut HashSet<FamilyName>,
533 errors: &mut Vec<String>,
534) -> Result<(), PrompterError> {
535 let entries = fs::read_dir(directory).map_err(|source| PrompterError::Io {
536 path: directory.to_path_buf(),
537 source,
538 })?;
539
540 for entry in entries {
541 let entry = entry.map_err(|source| PrompterError::Io {
542 path: directory.to_path_buf(),
543 source,
544 })?;
545 let path = entry.path();
546 let file_type = entry.file_type().map_err(|source| PrompterError::Io {
547 path: path.clone(),
548 source,
549 })?;
550 if !file_type.is_dir() {
551 continue;
552 }
553
554 if entry.file_name() == OsStr::new("families") {
555 inspect_families_directory(directory, &path, families, errors)?;
556 } else {
557 inspect_family_variants(&path, families, errors)?;
558 }
559 }
560
561 Ok(())
562}
563
564fn inspect_families_directory(
565 fragment_directory: &Path,
566 families_directory: &Path,
567 families: &mut HashSet<FamilyName>,
568 errors: &mut Vec<String>,
569) -> Result<(), PrompterError> {
570 let entries = fs::read_dir(families_directory).map_err(|source| PrompterError::Io {
571 path: families_directory.to_path_buf(),
572 source,
573 })?;
574
575 for entry in entries {
576 let entry = entry.map_err(|source| PrompterError::Io {
577 path: families_directory.to_path_buf(),
578 source,
579 })?;
580 let family_directory = entry.path();
581 let file_type = entry.file_type().map_err(|source| PrompterError::Io {
582 path: family_directory.clone(),
583 source,
584 })?;
585 if !file_type.is_dir() {
586 continue;
587 }
588
589 let family_os = entry.file_name();
590 let Some(family_text) = family_os.to_str() else {
591 errors.push(format!(
592 "Invalid non-UTF-8 family directory: {}",
593 family_directory.display()
594 ));
595 continue;
596 };
597 let family = match FamilyName::new(family_text) {
598 Ok(family) => family,
599 Err(error) => {
600 errors.push(format!("{error}: {}", family_directory.display()));
601 continue;
602 }
603 };
604 families.insert(family);
605
606 let variants = fs::read_dir(&family_directory).map_err(|source| PrompterError::Io {
607 path: family_directory.clone(),
608 source,
609 })?;
610 for variant in variants {
611 let variant = variant.map_err(|source| PrompterError::Io {
612 path: family_directory.clone(),
613 source,
614 })?;
615 let variant_path = variant.path();
616 let variant_type = variant.file_type().map_err(|source| PrompterError::Io {
617 path: variant_path.clone(),
618 source,
619 })?;
620 if !variant_type.is_file()
621 || !variant_path
622 .extension()
623 .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
624 {
625 continue;
626 }
627
628 let neutral_path = fragment_directory.join(variant.file_name());
629 if !neutral_path.is_file() {
630 errors.push(format!(
631 "Orphan family variant: {} does not shadow neutral fragment {}",
632 variant_path.display(),
633 neutral_path.display()
634 ));
635 }
636 }
637 }
638
639 Ok(())
640}
641
642pub fn validate(cfg: &Config) -> Result<(), PrompterError> {
655 let mut errors: Vec<String> = Vec::new();
656
657 for (profile_name, profile) in &cfg.profiles {
658 for dep in &profile.deps {
659 if std::path::Path::new(dep)
660 .extension()
661 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
662 {
663 let path = profile.library_root.join(dep);
664 if !path.exists() {
665 errors.push(format!(
666 "Missing file: {} (referenced by [{}])",
667 path.display(),
668 profile_name
669 ));
670 }
671 } else if !cfg.profiles.contains_key(dep) {
672 errors.push(format!(
673 "Unknown profile: {dep} (referenced by [{profile_name}])"
674 ));
675 }
676 }
677 }
678
679 let mut family_set = HashSet::new();
680 for library_root in cfg.library_roots() {
681 inspect_family_variants(&library_root, &mut family_set, &mut errors)?;
682 }
683 let mut families: Vec<_> = family_set.into_iter().collect();
684 families.sort();
685
686 for name in cfg.profiles.keys() {
687 for family in std::iter::once(None).chain(families.iter().map(Some)) {
688 let mut seen_files = HashSet::new();
689 let mut stack = Vec::new();
690 let mut out = Vec::new();
691 if let Err(error) =
692 resolve_profile_for_family(name, cfg, family, &mut seen_files, &mut stack, &mut out)
693 {
694 let message = error.to_string();
695 if !errors.contains(&message) {
696 errors.push(message);
697 }
698 }
699 }
700 }
701
702 if errors.is_empty() {
703 Ok(())
704 } else {
705 Err(PrompterError::Validation(errors.join("\n")))
706 }
707}
708
709fn build_tree_node(name: &str, cfg: &Config) -> TreeNode {
711 if std::path::Path::new(name)
713 .extension()
714 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
715 {
716 return TreeNode {
717 node_type: TreeNodeType::Fragment,
718 name: name.to_string(),
719 children: Vec::new(),
720 };
721 }
722
723 let children = cfg
725 .profiles
726 .get(name)
727 .map(|def| {
728 def.deps
729 .iter()
730 .map(|dep| build_tree_node(dep, cfg))
731 .collect()
732 })
733 .unwrap_or_default();
734
735 TreeNode {
736 node_type: TreeNodeType::Profile,
737 name: name.to_string(),
738 children,
739 }
740}
741
742fn find_root_profiles(cfg: &Config) -> Vec<String> {
744 let mut referenced = HashSet::new();
745
746 for profile in cfg.profiles.values() {
748 for dep in &profile.deps {
749 if !std::path::Path::new(dep)
751 .extension()
752 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
753 {
754 referenced.insert(dep.clone());
755 }
756 }
757 }
758
759 let mut roots: Vec<String> = cfg
761 .profiles
762 .keys()
763 .filter(|profile| !referenced.contains(*profile))
764 .cloned()
765 .collect();
766
767 roots.sort();
768 roots
769}
770
771fn build_trees(cfg: &Config) -> TreeOutput {
773 let root_profiles = find_root_profiles(cfg);
774 let trees = root_profiles
775 .iter()
776 .map(|profile| build_tree_node(profile, cfg))
777 .collect();
778
779 TreeOutput { trees }
780}
781
782fn print_tree(node: &TreeNode, prefix: &str, is_last: bool, w: &mut impl Write) -> io::Result<()> {
784 let connector = if is_last { "└── " } else { "├── " };
786 writeln!(w, "{prefix}{connector}{}", node.name)?;
787
788 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
790
791 for (i, child) in node.children.iter().enumerate() {
793 let is_last_child = i == node.children.len() - 1;
794 print_tree(child, &child_prefix, is_last_child, w)?;
795 }
796
797 Ok(())
798}
799
800pub fn show_tree(cfg: &Config, output: JsonOutput, mut w: impl Write) -> Result<(), PrompterError> {
802 let trees = build_trees(cfg);
803
804 if output.is_json() {
805 let data = serde_json::to_value(&trees)?;
806 writeln!(
807 &mut w,
808 "{}",
809 render_response("tree", JsonOutput::Json, data, String::new())
810 )
811 .map_err(PrompterError::Write)?;
812 } else {
813 for (i, tree) in trees.trees.iter().enumerate() {
814 writeln!(&mut w, "{}", tree.name).map_err(PrompterError::Write)?;
816
817 for (j, child) in tree.children.iter().enumerate() {
819 let is_last = j == tree.children.len() - 1;
820 print_tree(child, "", is_last, &mut w).map_err(PrompterError::Write)?;
821 }
822
823 if i < trees.trees.len() - 1 {
825 writeln!(&mut w).map_err(PrompterError::Write)?;
826 }
827 }
828 }
829
830 Ok(())
831}
832
833pub fn run_tree_stdout(
835 config_override: Option<&Path>,
836 output: JsonOutput,
837) -> Result<(), PrompterError> {
838 let (_cfg_path, cfg) = load_bundle(config_override)?;
839 show_tree(&cfg, output, io::stdout())
840}