use anodizer_core::log::{StageLogger, Verbosity};
use anyhow::{Context, Result};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, serde::Deserialize, Default)]
struct CargoToml {
workspace: Option<CargoWorkspace>,
package: Option<CargoPackage>,
bin: Option<Vec<CargoBin>>,
dependencies: Option<toml::Value>,
}
#[derive(Debug, serde::Deserialize)]
struct CargoWorkspace {
members: Option<Vec<String>>,
package: Option<CargoPackage>,
}
#[derive(Debug, serde::Deserialize, Clone)]
struct CargoPackage {
name: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct CargoBin {}
#[derive(Debug)]
struct CrateInfo {
name: String,
path: String,
is_binary: bool,
depends_on: Vec<String>, }
pub fn run() -> Result<()> {
let log = StageLogger::new("init", Verbosity::default());
let config_path = ".anodizer.yaml";
if std::path::Path::new(config_path).exists() {
anyhow::bail!("config file '{}' already exists", config_path);
}
let yaml = generate_config(".")?;
std::fs::write(config_path, &yaml)
.with_context(|| format!("failed to write {}", config_path))?;
log.status(&format!("Created {}", config_path));
let gitignore_path = ".gitignore";
let gitignore = std::fs::read_to_string(gitignore_path).unwrap_or_default();
if !gitignore.contains("dist/") {
let mut f = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(gitignore_path)
.with_context(|| format!("failed to open {}", gitignore_path))?;
use std::io::Write;
if !gitignore.is_empty() && !gitignore.ends_with('\n') {
writeln!(f)?;
}
writeln!(f, "dist/")?;
log.status(&format!("Added 'dist/' to {}", gitignore_path));
}
Ok(())
}
pub fn generate_config(root: &str) -> Result<String> {
let root_path = Path::new(root);
let cargo_path = root_path.join("Cargo.toml");
let cargo_content = std::fs::read_to_string(&cargo_path)
.with_context(|| format!("cannot read {}", cargo_path.display()))?;
let root_cargo: CargoToml = toml::from_str(&cargo_content)
.with_context(|| format!("cannot parse {}", cargo_path.display()))?;
let crates = if let Some(ws) = &root_cargo.workspace {
discover_workspace_crates(root, ws)?
} else {
let name = root_cargo
.package
.as_ref()
.and_then(|p| p.name.as_deref())
.map(|s| s.to_string())
.unwrap_or_else(|| {
root_path
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "project".to_string())
});
let is_binary = root_cargo
.bin
.as_ref()
.map(|b| !b.is_empty())
.unwrap_or(false)
|| root_path.join("src/main.rs").exists();
vec![CrateInfo {
name: name.clone(),
path: ".".to_string(),
is_binary,
depends_on: vec![],
}]
};
let project_name = root_cargo
.workspace
.as_ref()
.and_then(|ws| ws.package.as_ref())
.and_then(|p| p.name.as_deref())
.map(|s| s.to_string())
.or_else(|| {
root_cargo
.package
.as_ref()
.and_then(|p| p.name.as_deref())
.map(|s| s.to_string())
})
.unwrap_or_else(|| {
root_path
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "project".to_string())
});
let sorted = topological_sort(&crates);
render_yaml(&project_name, &sorted)
}
fn discover_workspace_crates(root: &str, ws: &CargoWorkspace) -> Result<Vec<CrateInfo>> {
let root_path = Path::new(root);
let members = ws.members.as_deref().unwrap_or(&[]);
let mut member_names: HashSet<String> = HashSet::new();
for glob_pattern in members {
for member_path in expand_glob(root, glob_pattern) {
let cargo_path = root_path.join(&member_path).join("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_path)
&& let Ok(cargo) = toml::from_str::<CargoToml>(&content)
&& let Some(name) = cargo.package.as_ref().and_then(|p| p.name.as_deref())
{
member_names.insert(name.to_string());
}
}
}
let mut crates = vec![];
for glob_pattern in members {
for member_path in expand_glob(root, glob_pattern) {
let cargo_path = root_path.join(&member_path).join("Cargo.toml");
if let Ok(content) = std::fs::read_to_string(&cargo_path)
&& let Ok(cargo) = toml::from_str::<CargoToml>(&content)
{
let name = match cargo.package.as_ref().and_then(|p| p.name.as_deref()) {
Some(n) => n.to_string(),
None => continue,
};
let is_binary = cargo.bin.as_ref().map(|b| !b.is_empty()).unwrap_or(false)
|| root_path.join(&member_path).join("src/main.rs").exists();
let depends_on = extract_workspace_deps(&cargo, &member_names);
crates.push(CrateInfo {
name,
path: member_path,
is_binary,
depends_on,
});
}
}
}
Ok(crates)
}
fn expand_glob(root: &str, pattern: &str) -> Vec<String> {
let root_path = Path::new(root);
if pattern.contains('*') {
let prefix = pattern.trim_end_matches('*').trim_end_matches('/');
let dir = root_path.join(prefix);
if let Ok(entries) = std::fs::read_dir(&dir) {
return entries
.flatten()
.filter(|e| e.path().is_dir())
.filter_map(|e| {
e.path()
.strip_prefix(root_path)
.ok()
.map(|p| p.to_string_lossy().to_string())
})
.collect();
}
vec![]
} else {
vec![pattern.to_string()]
}
}
fn extract_workspace_deps(cargo: &CargoToml, member_names: &HashSet<String>) -> Vec<String> {
let mut deps = vec![];
if let Some(toml::Value::Table(table)) = &cargo.dependencies {
for (dep_name, val) in table {
let is_member = member_names.contains(dep_name) && {
match val {
toml::Value::Table(t) => {
t.contains_key("path")
|| t.get("workspace")
.is_some_and(|v| v.as_bool() == Some(true))
}
_ => false,
}
};
if is_member {
deps.push(dep_name.clone());
}
}
}
deps.sort();
deps
}
fn topological_sort(crates: &[CrateInfo]) -> Vec<&CrateInfo> {
let items: Vec<(String, Vec<String>)> = crates
.iter()
.map(|c| (c.name.clone(), c.depends_on.clone()))
.collect();
let sorted_names = anodizer_core::util::topological_sort(&items);
let name_to_crate: HashMap<&str, &CrateInfo> =
crates.iter().map(|c| (c.name.as_str(), c)).collect();
sorted_names
.iter()
.filter_map(|name| name_to_crate.get(name.as_str()).copied())
.collect()
}
const COMMON_TARGETS: &[&str] = &[
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
];
fn render_yaml(project_name: &str, crates: &[&CrateInfo]) -> Result<String> {
let mut out = String::new();
out.push_str(&format!("project_name: {}\n", project_name));
out.push_str("dist: ./dist\n\n");
out.push_str("defaults:\n");
out.push_str(" targets:\n");
for t in COMMON_TARGETS {
out.push_str(&format!(" - {}\n", t));
}
out.push_str(" cross: auto\n\n");
out.push_str("crates:\n");
for c in crates {
out.push_str(&format!(" - name: {}\n", c.name));
out.push_str(&format!(" path: {}\n", c.path));
out.push_str(&format!(
" tag_template: \"{}-v{{{{ .Version }}}}\"\n",
c.name
));
if let Some(deps) = non_empty_deps(&c.depends_on) {
out.push_str(" depends_on:\n");
for d in deps {
out.push_str(&format!(" - {}\n", d));
}
}
if c.is_binary {
out.push_str(" builds:\n");
out.push_str(&format!(" - binary: {}\n", c.name));
out.push_str(" archives:\n");
out.push_str(&format!(
" - name_template: \"{}-{{{{ .Version }}}}-{{{{ .Os }}}}-{{{{ .Arch }}}}\"\n",
c.name
));
out.push_str(" release:\n");
out.push_str(" github:\n");
out.push_str(" owner: YOUR_GITHUB_OWNER\n");
out.push_str(&format!(" name: {}\n", project_name));
out.push_str(" draft: false\n");
out.push_str(" prerelease: auto\n");
} else {
out.push_str(" publish:\n");
out.push_str(" cargo: {}\n");
}
out.push('\n');
}
Ok(out)
}
fn non_empty_deps(deps: &[String]) -> Option<&[String]> {
if deps.is_empty() { None } else { Some(deps) }
}
const AUTO_EXCLUDED_PREFIXES: &[&str] = &["dist/", "target/", ".git/"];
const AUTO_EXCLUDED_FILENAMES: &[&str] = &["Cargo.toml", "Cargo.lock"];
pub fn enroll_version_files(
exclude: Vec<String>,
yes: bool,
verbose: bool,
debug: bool,
quiet: bool,
) -> Result<()> {
let log = StageLogger::new("init", Verbosity::from_flags(quiet, verbose, debug));
let config_path = ".anodizer.yaml";
if !Path::new(config_path).exists() {
anyhow::bail!(
"no '{config_path}' found — run `anodizer init` to scaffold one before enrolling version files"
);
}
let config_text = std::fs::read_to_string(config_path)
.with_context(|| format!("failed to read {config_path}"))?;
let already_enrolled = existing_version_files(&config_text);
let versions = scan_versions(Path::new("."))?;
if versions.is_empty() {
anyhow::bail!(
"could not determine the current version to scan for (no readable [package].version or [workspace.package].version)"
);
}
log.verbose(&format!("scanning for version(s): {}", versions.join(", ")));
let tracked = anodizer_core::git::list_tracked_files_in(Path::new("."))
.context("listing tracked files via `git ls-files`")?;
let exclude_globs = compile_globs(&exclude)?;
let candidates = discover_candidates(
Path::new("."),
&tracked,
&versions,
&already_enrolled,
&exclude_globs,
);
if candidates.is_empty() {
log.status("no un-enrolled files contain the current version — nothing to enroll");
return Ok(());
}
let selected = if yes {
candidates
} else {
select_interactive(&candidates)?
};
if selected.is_empty() {
log.status("no files selected — nothing to enroll");
return Ok(());
}
let (new_text, added) = add_version_files(&config_text, &selected)?;
if added.is_empty() {
log.status("all selected files were already enrolled — nothing to do");
return Ok(());
}
validate_enrolled_yaml(&new_text, &added)
.context("refusing to write .anodizer.yaml: the enrolled config did not validate")?;
std::fs::write(config_path, &new_text)
.with_context(|| format!("failed to write {config_path}"))?;
log.status(&format!(
"enrolled {} file(s) under version_files in {config_path}",
added.len()
));
for path in &added {
log.status(&format!(" + {path}"));
}
Ok(())
}
fn scan_versions(root: &Path) -> Result<Vec<String>> {
use crate::commands::bump::cargo_edit::load_workspace;
use anodizer_stage_build::version_sync::read_cargo_version;
let mut versions: Vec<String> = Vec::new();
let mut push = |v: String| {
if v != "0.0.0" && !versions.contains(&v) {
versions.push(v);
}
};
if let Ok(ws) = load_workspace(root) {
if let Some(v) = ws.workspace_package_version.clone() {
push(v);
}
for member in &ws.members {
if let Some(v) = member.own_version.clone() {
push(v);
}
}
}
if let Ok(v) = read_cargo_version(&root.to_string_lossy()) {
push(v);
}
Ok(versions)
}
fn existing_version_files(config_text: &str) -> HashSet<String> {
let mut out = HashSet::new();
let mut in_block = false;
for line in config_text.lines() {
let trimmed = line.trim_start();
if !in_block {
if let Some(inline) = line.strip_prefix("version_files:") {
let inline = inline.trim();
if inline.is_empty() {
in_block = true;
} else {
for item in parse_flow_members(inline) {
out.insert(item);
}
}
}
continue;
}
if let Some(item) = parse_list_item(line) {
out.insert(item);
} else if trimmed.is_empty() {
continue;
} else {
break;
}
}
out
}
fn version_files_is_flow_style(config_text: &str) -> bool {
config_text.lines().any(|line| {
line.strip_prefix("version_files:")
.map(|rest| rest.trim().starts_with('['))
.unwrap_or(false)
})
}
fn parse_flow_members(inline: &str) -> Vec<String> {
let inner = inline
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(inline);
inner
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(unquote_scalar)
.collect()
}
fn unquote_scalar(val: &str) -> String {
val.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| val.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(val)
.to_string()
}
fn parse_list_item(line: &str) -> Option<String> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("- ")?;
Some(unquote_scalar(rest.trim()))
}
fn yaml_scalar(path: &str) -> String {
if is_plain_yaml_scalar(path) {
path.to_string()
} else {
let escaped = path.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
}
fn is_plain_yaml_scalar(s: &str) -> bool {
if s.is_empty() {
return false;
}
let first = s.chars().next().unwrap_or(' ');
if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
return false;
}
!s.chars()
.any(|c| c.is_whitespace() || c == ':' || c == '#' || c == '\t')
}
const EXCLUDE_MATCH_OPTIONS: glob::MatchOptions = glob::MatchOptions {
case_sensitive: true,
require_literal_separator: true,
require_literal_leading_dot: false,
};
fn compile_globs(patterns: &[String]) -> Result<Vec<glob::Pattern>> {
patterns
.iter()
.map(|p| {
glob::Pattern::new(p).with_context(|| format!("invalid --exclude glob pattern {p:?}"))
})
.collect()
}
fn discover_candidates(
root: &Path,
tracked: &[String],
versions: &[String],
already_enrolled: &HashSet<String>,
exclude_globs: &[glob::Pattern],
) -> Vec<String> {
let mut out: Vec<String> = tracked
.iter()
.filter(|p| !is_auto_excluded(p))
.filter(|p| !already_enrolled.contains(*p))
.filter(|p| {
!exclude_globs
.iter()
.any(|g| g.matches_with(p, EXCLUDE_MATCH_OPTIONS))
})
.filter(|p| file_contains_any_version(&root.join(p), versions))
.cloned()
.collect();
out.sort();
out.dedup();
out
}
fn is_auto_excluded(path: &str) -> bool {
if AUTO_EXCLUDED_PREFIXES.iter().any(|p| path.starts_with(p)) {
return true;
}
let name = Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
AUTO_EXCLUDED_FILENAMES.contains(&name.as_str())
}
fn file_contains_any_version(path: &Path, versions: &[String]) -> bool {
let content = match std::fs::read(path) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(_) => return false,
},
Err(_) => return false,
};
versions
.iter()
.any(|v| anodizer_core::version_files::contains_version(&content, v).unwrap_or(false))
}
fn select_interactive(candidates: &[String]) -> Result<Vec<String>> {
let defaults: Vec<bool> = vec![true; candidates.len()];
let chosen = dialoguer::MultiSelect::new()
.with_prompt("Select files to enroll under version_files (space toggles, enter confirms)")
.items(candidates)
.defaults(&defaults)
.interact_opt()
.context("version-files selection prompt failed")?;
Ok(match chosen {
Some(indices) => indices
.into_iter()
.filter_map(|i| candidates.get(i).cloned())
.collect(),
None => Vec::new(),
})
}
const DEFAULT_BLOCK_INDENT: &str = " ";
struct BlockInsertion {
insert_at: usize,
indent: String,
}
fn add_version_files(config_text: &str, selected: &[String]) -> Result<(String, Vec<String>)> {
if version_files_is_flow_style(config_text) {
anyhow::bail!(
"`version_files` is written as an inline (flow) list in .anodizer.yaml; \
rewrite it as a block list (one `- path` per line under `version_files:`) \
to enroll automatically"
);
}
let existing = existing_version_files(config_text);
let mut to_add: Vec<String> = Vec::new();
for path in selected {
if !existing.contains(path) && !to_add.contains(path) {
to_add.push(path.clone());
}
}
if to_add.is_empty() {
return Ok((config_text.to_string(), to_add));
}
let new_text = match find_version_files_block(config_text) {
Some(BlockInsertion { insert_at, indent }) => {
let mut lines: Vec<String> = config_text.lines().map(str::to_string).collect();
let block = to_add
.iter()
.map(|p| format!("{indent}- {}", yaml_scalar(p)));
let tail = lines.split_off(insert_at);
lines.extend(block);
lines.extend(tail);
let trailing_newline = config_text.ends_with('\n');
let mut joined = lines.join("\n");
if trailing_newline {
joined.push('\n');
}
joined
}
None => {
let mut out = config_text.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() {
out.push('\n');
}
out.push_str("version_files:\n");
for path in &to_add {
out.push_str(&format!("{DEFAULT_BLOCK_INDENT}- {}\n", yaml_scalar(path)));
}
out
}
};
Ok((new_text, to_add))
}
fn find_version_files_block(config_text: &str) -> Option<BlockInsertion> {
let lines: Vec<&str> = config_text.lines().collect();
let mut start: Option<usize> = None;
for (idx, line) in lines.iter().enumerate() {
if let Some(rest) = line.strip_prefix("version_files:")
&& rest.trim().is_empty()
{
start = Some(idx);
break;
}
}
let start = start?;
let mut last_item = start;
let mut indent: Option<String> = None;
for (offset, line) in lines.iter().enumerate().skip(start + 1) {
if parse_list_item(line).is_some() {
last_item = offset;
if indent.is_none() {
let lead: String = line.chars().take_while(|c| c.is_whitespace()).collect();
indent = Some(lead);
}
} else if line.trim().is_empty() {
continue;
} else {
break;
}
}
Some(BlockInsertion {
insert_at: last_item + 1,
indent: indent.unwrap_or_else(|| DEFAULT_BLOCK_INDENT.to_string()),
})
}
fn validate_enrolled_yaml(new_text: &str, added: &[String]) -> Result<()> {
let config: anodizer_core::config::Config = serde_yaml_ng::from_str(new_text)
.context("rewritten config is not valid YAML / config schema")?;
let enrolled = config.version_files.unwrap_or_default();
for path in added {
if !enrolled.contains(path) {
anyhow::bail!("enrolled path {path:?} is missing from version_files after the edit");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_file(dir: &Path, rel: &str, content: &str) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, content).unwrap();
}
#[test]
fn test_single_crate_binary() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[package]
name = "myapp"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "myapp"
path = "src/main.rs"
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("project_name: myapp"));
assert!(yaml.contains("builds:"));
assert!(yaml.contains("binary: myapp"));
assert!(yaml.contains("archives:"));
assert!(yaml.contains("release:"));
assert!(!yaml.contains("cargo: {}"));
}
#[test]
fn test_single_crate_library() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[package]
name = "mylib"
version = "0.1.0"
edition = "2024"
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("project_name: mylib"));
assert!(yaml.contains("cargo: {}"));
assert!(!yaml.contains("builds:"));
}
#[test]
fn test_workspace_with_mixed_crates() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[workspace]
resolver = "2"
members = ["crates/mylib", "crates/mybin"]
[workspace.package]
name = "myproject"
version = "0.1.0"
edition = "2024"
"#,
);
write_file(
tmp.path(),
"crates/mylib/Cargo.toml",
r#"
[package]
name = "mylib"
version = "0.1.0"
edition = "2024"
"#,
);
write_file(
tmp.path(),
"crates/mybin/Cargo.toml",
r#"
[package]
name = "mybin"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "mybin"
path = "src/main.rs"
[dependencies]
mylib = { path = "../mylib" }
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("project_name: myproject"));
assert!(yaml.contains("cargo: {}"));
assert!(yaml.contains("builds:"));
assert!(yaml.contains("binary: mybin"));
assert!(yaml.contains("depends_on:"));
assert!(yaml.contains("- mylib"));
let mylib_pos = yaml.find("name: mylib").unwrap();
let mybin_pos = yaml.find("name: mybin").unwrap();
assert!(mylib_pos < mybin_pos, "mylib should appear before mybin");
}
#[test]
fn test_workspace_glob_expansion() {
let tmp = TempDir::new().unwrap();
write_file(
tmp.path(),
"Cargo.toml",
r#"
[workspace]
resolver = "2"
members = ["crates/*"]
"#,
);
write_file(
tmp.path(),
"crates/alpha/Cargo.toml",
r#"
[package]
name = "alpha"
version = "0.1.0"
edition = "2024"
"#,
);
write_file(
tmp.path(),
"crates/beta/Cargo.toml",
r#"
[package]
name = "beta"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "beta"
path = "src/main.rs"
"#,
);
let yaml = generate_config(tmp.path().to_str().unwrap()).unwrap();
assert!(yaml.contains("name: alpha"));
assert!(yaml.contains("name: beta"));
assert!(yaml.contains("binary: beta"));
}
#[test]
fn existing_version_files_parses_block_and_flow() {
let block = "project_name: app\nversion_files:\n - a.md\n - \"b c.md\"\n";
let got = existing_version_files(block);
assert!(got.contains("a.md"));
assert!(got.contains("b c.md"));
let flow = "project_name: app\nversion_files: [a.md, \"b c.md\"]\n";
let got = existing_version_files(flow);
assert!(got.contains("a.md"), "flow members not parsed: {got:?}");
assert!(got.contains("b c.md"), "flow members not parsed: {got:?}");
}
#[test]
fn add_version_files_bails_on_flow_style() {
let cfg = "project_name: app\nversion_files: [a.md]\n";
let err = add_version_files(cfg, &["b.md".to_string()]).unwrap_err();
assert!(err.to_string().contains("inline"), "err: {err}");
assert!(err.to_string().contains("block list"), "err: {err}");
}
#[test]
fn add_version_files_matches_four_space_indent() {
let cfg = "project_name: app\nversion_files:\n - a.md\n";
let (out, added) = add_version_files(cfg, &["b.md".to_string()]).unwrap();
assert_eq!(added, vec!["b.md".to_string()]);
assert!(out.contains(" - b.md"), "indent not matched:\n{out}");
let cfg: anodizer_core::config::Config = serde_yaml_ng::from_str(&out).unwrap();
let vf = cfg.version_files.unwrap();
assert!(vf.contains(&"a.md".to_string()));
assert!(vf.contains(&"b.md".to_string()));
}
#[test]
fn add_version_files_appends_block_when_absent() {
let cfg = "project_name: app\n";
let (out, _) = add_version_files(cfg, &["a.md".to_string()]).unwrap();
assert!(out.contains("version_files:\n - a.md\n"), "out:\n{out}");
}
#[test]
fn yaml_scalar_quotes_special_paths() {
assert_eq!(yaml_scalar("plain/path.md"), "plain/path.md");
assert_eq!(yaml_scalar("with space.md"), "\"with space.md\"");
assert_eq!(yaml_scalar("a:b.md"), "\"a:b.md\"");
assert_eq!(yaml_scalar("# leading.md"), "\"# leading.md\"");
assert_eq!(yaml_scalar("[bracket].md"), "\"[bracket].md\"");
let line = format!(" - {}", yaml_scalar("with space.md"));
assert_eq!(parse_list_item(&line).as_deref(), Some("with space.md"));
}
#[test]
fn add_version_files_quotes_spaced_path_and_validates() {
let cfg = "project_name: app\n";
let (out, added) = add_version_files(cfg, &["with space.md".to_string()]).unwrap();
assert!(out.contains("- \"with space.md\""), "not quoted:\n{out}");
validate_enrolled_yaml(&out, &added).unwrap();
}
#[test]
fn validate_enrolled_yaml_rejects_invalid() {
let bad = "project_name: app\nnot_a_real_key: true\n";
assert!(validate_enrolled_yaml(bad, &[]).is_err());
}
}