use crate::config::Config;
use crate::config::load_bundle;
use crate::{
ProfileDef, PrompterError, home_dir, parse_config_file, read_config_with_path, unescape,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs;
use std::io;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use tftio_cli_common::{JsonOutput, render_response};
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FamilyName(String);
impl FamilyName {
pub fn new(value: impl Into<String>) -> Result<Self, InvalidFamilyName> {
let value = value.into();
let mut components = Path::new(&value).components();
let is_single_component = matches!(
components.next(),
Some(Component::Normal(component)) if component == OsStr::new(&value)
) && components.next().is_none();
if is_single_component {
Ok(Self(value))
} else {
Err(InvalidFamilyName(value))
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for FamilyName {
type Err = InvalidFamilyName;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
impl std::fmt::Display for FamilyName {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("family name must be one non-empty path component: {0}")]
pub struct InvalidFamilyName(String);
pub(crate) fn collect_profiles(
prefix: &str,
value: &toml::Value,
out: &mut HashMap<String, Vec<String>>,
) -> Result<(), PrompterError> {
let Some(table) = value.as_table() else {
return Ok(());
};
if let Some(deps_value) = table.get("depends_on") {
let arr = deps_value.as_array().ok_or_else(|| {
PrompterError::ConfigField(format!(
"`depends_on` for [{prefix}] must be an array of strings"
))
})?;
let mut deps = Vec::with_capacity(arr.len());
for entry in arr {
let s = entry.as_str().ok_or_else(|| {
PrompterError::ConfigField(format!(
"`depends_on` entries for [{prefix}] must be strings"
))
})?;
deps.push(s.to_string());
}
if out.insert(prefix.to_string(), deps).is_some() {
return Err(PrompterError::DuplicateProfile(format!(
"Duplicate profile definition: [{prefix}]"
)));
}
}
for (sub_key, sub_val) in table {
if sub_key == "depends_on" {
continue;
}
let new_prefix = if prefix.is_empty() {
sub_key.clone()
} else {
format!("{prefix}.{sub_key}")
};
collect_profiles(&new_prefix, sub_val, out)?;
}
Ok(())
}
pub(crate) fn expand_tilde(s: &str) -> Result<PathBuf, PrompterError> {
if let Some(rest) = s.strip_prefix("~/") {
Ok(home_dir()?.join(rest))
} else if s == "~" {
home_dir()
} else {
Ok(PathBuf::from(s))
}
}
fn resolve_import_path(importer_dir: &Path, import_str: &str) -> Result<PathBuf, PrompterError> {
let p = expand_tilde(import_str)?;
if p.is_absolute() {
Ok(p)
} else {
Ok(importer_dir.join(p))
}
}
fn library_root_for(
config_path: &Path,
raw_library: Option<&str>,
default_library: Option<&Path>,
) -> Result<PathBuf, PrompterError> {
let config_dir = config_path
.parent()
.ok_or_else(|| PrompterError::NoParentDir(config_path.to_path_buf()))?;
if let Some(lib_str) = raw_library {
let p = expand_tilde(lib_str)?;
return Ok(if p.is_absolute() {
p
} else {
config_dir.join(p)
});
}
if let Some(default) = default_library {
return Ok(default.to_path_buf());
}
Ok(config_dir.join("library"))
}
pub fn load_config_bundle(
primary_path: &Path,
primary_default_library: Option<&Path>,
) -> Result<Config, PrompterError> {
let mut profiles: HashMap<String, ProfileDef> = HashMap::new();
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut post_prompt: Option<String> = None;
load_one(
primary_path,
primary_default_library,
true,
&mut profiles,
&mut visited,
&mut post_prompt,
)?;
Ok(Config {
profiles,
post_prompt,
})
}
fn load_one(
path: &Path,
default_library: Option<&Path>,
is_primary: bool,
profiles: &mut HashMap<String, ProfileDef>,
visited: &mut HashSet<PathBuf>,
post_prompt: &mut Option<String>,
) -> Result<(), PrompterError> {
let text = read_config_with_path(path)?;
let canonical = fs::canonicalize(path).map_err(|source| PrompterError::Io {
path: path.to_path_buf(),
source,
})?;
if !visited.insert(canonical.clone()) {
return Err(PrompterError::ImportCycle(canonical));
}
let raw = parse_config_file(&text)?;
let library_root = library_root_for(&canonical, raw.library.as_deref(), default_library)?;
for (name, deps) in raw.profiles {
if let Some(existing) = profiles.get(&name) {
return Err(PrompterError::DuplicateProfile(format!(
"Duplicate profile `{}` defined in both {} and {}",
name,
existing.library_root.display(),
library_root.display()
)));
}
profiles.insert(
name,
ProfileDef {
deps,
library_root: library_root.clone(),
},
);
}
if is_primary {
*post_prompt = raw.post_prompt.map(|s| unescape(&s));
}
let importer_dir = canonical
.parent()
.ok_or_else(|| PrompterError::NoParentDir(canonical.clone()))?
.to_path_buf();
for import_str in raw.imports {
let import_path = resolve_import_path(&importer_dir, &import_str)?;
load_one(&import_path, None, false, profiles, visited, post_prompt)?;
}
Ok(())
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum ResolveError {
#[error("Unknown profile: {0}")]
UnknownProfile(String),
#[error("Cycle detected: {}", .0.join(" -> "))]
Cycle(Vec<String>),
#[error("Missing file: {path} (referenced by [{referenced_by}])", path = .0.display(), referenced_by = .1)]
MissingFile(PathBuf, String), }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TreeNodeType {
Profile,
Fragment,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeNode {
#[serde(rename = "type")]
pub node_type: TreeNodeType,
pub name: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub children: Vec<TreeNode>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TreeOutput {
pub trees: Vec<TreeNode>,
}
#[allow(
clippy::implicit_hasher,
reason = "public resolver API intentionally fixes the std default HashMap hasher rather than exposing a hasher type parameter"
)]
pub fn resolve_profile(
name: &str,
cfg: &Config,
seen_files: &mut HashSet<PathBuf>,
stack: &mut Vec<String>,
out: &mut Vec<(PathBuf, PathBuf)>,
) -> Result<(), ResolveError> {
resolve_profile_for_family(name, cfg, None, seen_files, stack, out)
}
pub(crate) fn resolve_profile_for_family(
name: &str,
cfg: &Config,
family: Option<&FamilyName>,
seen_files: &mut HashSet<PathBuf>,
stack: &mut Vec<String>,
out: &mut Vec<(PathBuf, PathBuf)>,
) -> Result<(), ResolveError> {
if stack.contains(&name.to_string()) {
let mut cycle = stack.clone();
cycle.push(name.to_string());
return Err(ResolveError::Cycle(cycle));
}
let profile = cfg
.profiles
.get(name)
.ok_or_else(|| ResolveError::UnknownProfile(name.to_string()))?;
stack.push(name.to_string());
for dep in &profile.deps {
if std::path::Path::new(dep)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
let path = profile.library_root.join(dep);
if !path.exists() {
return Err(ResolveError::MissingFile(path, name.to_string()));
}
if seen_files.insert(path.clone()) {
let rendered_path = family.map_or_else(
|| path.clone(),
|family| {
let variant = path.parent().map_or_else(
|| path.clone(),
|parent| {
parent
.join("families")
.join(family.as_str())
.join(path.file_name().unwrap_or_default())
},
);
if variant.is_file() {
variant
} else {
path.clone()
}
},
);
out.push((rendered_path, profile.library_root.clone()));
}
} else {
resolve_profile_for_family(dep, cfg, family, seen_files, stack, out)?;
}
}
stack.pop();
Ok(())
}
#[derive(Debug, Serialize)]
struct ListOutput {
profiles: Vec<ProfileInfo>,
libraries: Vec<LibraryOutput>,
}
#[derive(Debug, Serialize)]
struct ProfileInfo {
name: String,
dependencies: Vec<String>,
library_root: String,
}
#[derive(Debug, Serialize)]
struct LibraryOutput {
path: String,
fragments: Vec<String>,
}
pub fn list_profiles(
cfg: &Config,
output: JsonOutput,
mut w: impl Write,
) -> Result<(), PrompterError> {
if output.is_json() {
let mut unique_roots: Vec<PathBuf> = Vec::new();
for profile in cfg.profiles.values() {
if !unique_roots.contains(&profile.library_root) {
unique_roots.push(profile.library_root.clone());
}
}
unique_roots.sort();
let mut libraries = Vec::with_capacity(unique_roots.len());
for root in &unique_roots {
let mut fragments = Vec::new();
if root.exists() {
collect_fragments(root, root, &mut fragments)?;
}
fragments.sort();
libraries.push(LibraryOutput {
path: root.display().to_string(),
fragments,
});
}
let mut profiles: Vec<ProfileInfo> = cfg
.profiles
.iter()
.map(|(name, def)| ProfileInfo {
name: name.clone(),
dependencies: def.deps.clone(),
library_root: def.library_root.display().to_string(),
})
.collect();
profiles.sort_by(|a, b| a.name.cmp(&b.name));
let data = serde_json::to_value(ListOutput {
profiles,
libraries,
})?;
writeln!(
&mut w,
"{}",
render_response("list", JsonOutput::Json, data, String::new())
)
.map_err(PrompterError::Write)?;
} else {
let mut names: Vec<_> = cfg.profiles.keys().cloned().collect();
names.sort();
for n in names {
writeln!(&mut w, "{n}").map_err(PrompterError::Write)?;
}
}
Ok(())
}
fn collect_fragments(
root: &Path,
dir: &Path,
fragments: &mut Vec<String>,
) -> Result<(), PrompterError> {
let entries = fs::read_dir(dir).map_err(|source| PrompterError::Io {
path: dir.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| PrompterError::Io {
path: dir.to_path_buf(),
source,
})?;
let path = entry.path();
if path.is_dir() {
collect_fragments(root, &path, fragments)?;
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
if let Ok(rel_path) = path.strip_prefix(root) {
fragments.push(rel_path.display().to_string());
}
}
}
Ok(())
}
fn inspect_family_variants(
directory: &Path,
families: &mut HashSet<FamilyName>,
errors: &mut Vec<String>,
) -> Result<(), PrompterError> {
let entries = fs::read_dir(directory).map_err(|source| PrompterError::Io {
path: directory.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| PrompterError::Io {
path: directory.to_path_buf(),
source,
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|source| PrompterError::Io {
path: path.clone(),
source,
})?;
if !file_type.is_dir() {
continue;
}
if entry.file_name() == OsStr::new("families") {
inspect_families_directory(directory, &path, families, errors)?;
} else {
inspect_family_variants(&path, families, errors)?;
}
}
Ok(())
}
fn inspect_families_directory(
fragment_directory: &Path,
families_directory: &Path,
families: &mut HashSet<FamilyName>,
errors: &mut Vec<String>,
) -> Result<(), PrompterError> {
let entries = fs::read_dir(families_directory).map_err(|source| PrompterError::Io {
path: families_directory.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| PrompterError::Io {
path: families_directory.to_path_buf(),
source,
})?;
let family_directory = entry.path();
let file_type = entry.file_type().map_err(|source| PrompterError::Io {
path: family_directory.clone(),
source,
})?;
if !file_type.is_dir() {
continue;
}
let family_os = entry.file_name();
let Some(family_text) = family_os.to_str() else {
errors.push(format!(
"Invalid non-UTF-8 family directory: {}",
family_directory.display()
));
continue;
};
let family = match FamilyName::new(family_text) {
Ok(family) => family,
Err(error) => {
errors.push(format!("{error}: {}", family_directory.display()));
continue;
}
};
families.insert(family);
let variants = fs::read_dir(&family_directory).map_err(|source| PrompterError::Io {
path: family_directory.clone(),
source,
})?;
for variant in variants {
let variant = variant.map_err(|source| PrompterError::Io {
path: family_directory.clone(),
source,
})?;
let variant_path = variant.path();
let variant_type = variant.file_type().map_err(|source| PrompterError::Io {
path: variant_path.clone(),
source,
})?;
if !variant_type.is_file()
|| !variant_path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
{
continue;
}
let neutral_path = fragment_directory.join(variant.file_name());
if !neutral_path.is_file() {
errors.push(format!(
"Orphan family variant: {} does not shadow neutral fragment {}",
variant_path.display(),
neutral_path.display()
));
}
}
}
Ok(())
}
pub fn validate(cfg: &Config) -> Result<(), PrompterError> {
let mut errors: Vec<String> = Vec::new();
for (profile_name, profile) in &cfg.profiles {
for dep in &profile.deps {
if std::path::Path::new(dep)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
let path = profile.library_root.join(dep);
if !path.exists() {
errors.push(format!(
"Missing file: {} (referenced by [{}])",
path.display(),
profile_name
));
}
} else if !cfg.profiles.contains_key(dep) {
errors.push(format!(
"Unknown profile: {dep} (referenced by [{profile_name}])"
));
}
}
}
let mut family_set = HashSet::new();
for library_root in cfg.library_roots() {
inspect_family_variants(&library_root, &mut family_set, &mut errors)?;
}
let mut families: Vec<_> = family_set.into_iter().collect();
families.sort();
for name in cfg.profiles.keys() {
for family in std::iter::once(None).chain(families.iter().map(Some)) {
let mut seen_files = HashSet::new();
let mut stack = Vec::new();
let mut out = Vec::new();
if let Err(error) =
resolve_profile_for_family(name, cfg, family, &mut seen_files, &mut stack, &mut out)
{
let message = error.to_string();
if !errors.contains(&message) {
errors.push(message);
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(PrompterError::Validation(errors.join("\n")))
}
}
fn build_tree_node(name: &str, cfg: &Config) -> TreeNode {
if std::path::Path::new(name)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
return TreeNode {
node_type: TreeNodeType::Fragment,
name: name.to_string(),
children: Vec::new(),
};
}
let children = cfg
.profiles
.get(name)
.map(|def| {
def.deps
.iter()
.map(|dep| build_tree_node(dep, cfg))
.collect()
})
.unwrap_or_default();
TreeNode {
node_type: TreeNodeType::Profile,
name: name.to_string(),
children,
}
}
fn find_root_profiles(cfg: &Config) -> Vec<String> {
let mut referenced = HashSet::new();
for profile in cfg.profiles.values() {
for dep in &profile.deps {
if !std::path::Path::new(dep)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
referenced.insert(dep.clone());
}
}
}
let mut roots: Vec<String> = cfg
.profiles
.keys()
.filter(|profile| !referenced.contains(*profile))
.cloned()
.collect();
roots.sort();
roots
}
fn build_trees(cfg: &Config) -> TreeOutput {
let root_profiles = find_root_profiles(cfg);
let trees = root_profiles
.iter()
.map(|profile| build_tree_node(profile, cfg))
.collect();
TreeOutput { trees }
}
fn print_tree(node: &TreeNode, prefix: &str, is_last: bool, w: &mut impl Write) -> io::Result<()> {
let connector = if is_last { "└── " } else { "├── " };
writeln!(w, "{prefix}{connector}{}", node.name)?;
let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
for (i, child) in node.children.iter().enumerate() {
let is_last_child = i == node.children.len() - 1;
print_tree(child, &child_prefix, is_last_child, w)?;
}
Ok(())
}
pub fn show_tree(cfg: &Config, output: JsonOutput, mut w: impl Write) -> Result<(), PrompterError> {
let trees = build_trees(cfg);
if output.is_json() {
let data = serde_json::to_value(&trees)?;
writeln!(
&mut w,
"{}",
render_response("tree", JsonOutput::Json, data, String::new())
)
.map_err(PrompterError::Write)?;
} else {
for (i, tree) in trees.trees.iter().enumerate() {
writeln!(&mut w, "{}", tree.name).map_err(PrompterError::Write)?;
for (j, child) in tree.children.iter().enumerate() {
let is_last = j == tree.children.len() - 1;
print_tree(child, "", is_last, &mut w).map_err(PrompterError::Write)?;
}
if i < trees.trees.len() - 1 {
writeln!(&mut w).map_err(PrompterError::Write)?;
}
}
}
Ok(())
}
pub fn run_tree_stdout(
config_override: Option<&Path>,
output: JsonOutput,
) -> Result<(), PrompterError> {
let (_cfg_path, cfg) = load_bundle(config_override)?;
show_tree(&cfg, output, io::stdout())
}