#![allow(clippy::missing_errors_doc)]
use anyhow::{Context, Result};
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use toml::Value as TomlValue;
use crate::output::CommandOutcome;
const SKILL_CONTENT: &str = include_str!("../../templates/skill-hyalo.md");
const TIDY_SKILL_CONTENT: &str = include_str!("../../templates/skill-hyalo-tidy.md");
const RULE_TEMPLATE: &str = include_str!("../../templates/rule-knowledgebase.md");
const CLAUDE_MD_HINT: &str = "Use `hyalo` CLI (not Read/Grep/Glob) for all markdown knowledgebase operations.\n\
Examples: `hyalo find --property status=planned --format text`, `hyalo find \"search text\"`, `hyalo find --property 'title~=pattern'`.\n\
Run `hyalo --help` for usage. Use `--format text` for compact LLM-friendly output.";
const SECTION_START: &str = "<!-- hyalo:start -->";
const SECTION_END: &str = "<!-- hyalo:end -->";
const CANDIDATE_DIRS: &[&str] = &["docs", "knowledgebase", "wiki", "notes", "content", "pages"];
pub fn run_init(dir: Option<&str>, claude: bool) -> Result<CommandOutcome> {
let cwd = std::env::current_dir().context("failed to determine current working directory")?;
run_init_in(dir, claude, &cwd)
}
fn run_init_in(dir: Option<&str>, claude: bool, cwd: &Path) -> Result<CommandOutcome> {
let mut summary = String::new();
let dir_explicit = dir.is_some();
let dir_value = match dir {
Some(d) => d.to_owned(),
None => auto_detect_dir(cwd),
};
let toml_path = cwd.join(".hyalo.toml");
let toml_existed = toml_path.exists();
if toml_existed && !dir_explicit {
writeln!(summary, "skipped .hyalo.toml (already exists)").unwrap();
} else {
let toml_content = if toml_existed {
let existing_raw = fs::read_to_string(&toml_path)
.with_context(|| format!("failed to read {}", toml_path.display()))?;
if let Ok(mut table) = toml::from_str::<toml::Table>(&existing_raw) {
table.insert("dir".to_owned(), TomlValue::String(dir_value.clone()));
if let Ok(s) = toml::to_string(&table) {
s
} else {
writeln!(
summary,
"warning .hyalo.toml was malformed; existing content replaced"
)
.unwrap();
minimal_toml_dir(&dir_value)
}
} else {
writeln!(
summary,
"warning .hyalo.toml was malformed; existing content replaced"
)
.unwrap();
minimal_toml_dir(&dir_value)
}
} else {
minimal_toml_dir(&dir_value)
};
fs::write(&toml_path, &toml_content)
.with_context(|| format!("failed to write {}", toml_path.display()))?;
if toml_existed {
writeln!(summary, "updated .hyalo.toml (dir = \"{dir_value}\")").unwrap();
} else {
writeln!(summary, "created .hyalo.toml (dir = \"{dir_value}\")").unwrap();
}
}
if !claude {
return Ok(CommandOutcome::RawOutput(summary.trim_end().to_owned()));
}
let skill_path = cwd
.join(".claude")
.join("skills")
.join("hyalo")
.join("SKILL.md");
let skill_existed = skill_path.exists();
let skill_dir = skill_path
.parent()
.context("skill path has no parent directory")?;
fs::create_dir_all(skill_dir)
.with_context(|| format!("failed to create directory {}", skill_dir.display()))?;
fs::write(
&skill_path,
parameterize_template(SKILL_CONTENT, &dir_value),
)
.with_context(|| format!("failed to write {}", skill_path.display()))?;
if skill_existed {
writeln!(summary, "updated .claude/skills/hyalo/SKILL.md").unwrap();
} else {
writeln!(summary, "created .claude/skills/hyalo/SKILL.md").unwrap();
}
let tidy_skill_path = cwd
.join(".claude")
.join("skills")
.join("hyalo-tidy")
.join("SKILL.md");
let tidy_skill_existed = tidy_skill_path.exists();
let tidy_skill_dir = tidy_skill_path
.parent()
.context("tidy skill path has no parent directory")?;
fs::create_dir_all(tidy_skill_dir)
.with_context(|| format!("failed to create directory {}", tidy_skill_dir.display()))?;
fs::write(
&tidy_skill_path,
parameterize_template(TIDY_SKILL_CONTENT, &dir_value),
)
.with_context(|| format!("failed to write {}", tidy_skill_path.display()))?;
if tidy_skill_existed {
writeln!(summary, "updated .claude/skills/hyalo-tidy/SKILL.md").unwrap();
} else {
writeln!(summary, "created .claude/skills/hyalo-tidy/SKILL.md").unwrap();
}
let rules_path = cwd.join(".claude").join("rules").join("knowledgebase.md");
let rules_existed = rules_path.exists();
let rules_dir = rules_path
.parent()
.context("rules path has no parent directory")?;
fs::create_dir_all(rules_dir)
.with_context(|| format!("failed to create directory {}", rules_dir.display()))?;
let rule_content = parameterize_rule(RULE_TEMPLATE, &dir_value);
fs::write(&rules_path, &rule_content)
.with_context(|| format!("failed to write {}", rules_path.display()))?;
if rules_existed {
writeln!(summary, "updated .claude/rules/knowledgebase.md").unwrap();
} else {
writeln!(summary, "created .claude/rules/knowledgebase.md").unwrap();
}
let claude_md_path = cwd.join(".claude").join("CLAUDE.md");
let managed_section = format!("{SECTION_START}\n{CLAUDE_MD_HINT}\n{SECTION_END}");
if claude_md_path.exists() {
let existing = fs::read_to_string(&claude_md_path)
.with_context(|| format!("failed to read {}", claude_md_path.display()))?;
let (new_content, action) = upsert_managed_section(&existing, &managed_section);
fs::write(&claude_md_path, &new_content)
.with_context(|| format!("failed to write {}", claude_md_path.display()))?;
writeln!(summary, "updated .claude/CLAUDE.md ({action})").unwrap();
} else {
let claude_dir = claude_md_path
.parent()
.context("CLAUDE.md path has no parent directory")?;
fs::create_dir_all(claude_dir)
.with_context(|| format!("failed to create directory {}", claude_dir.display()))?;
let content = format!("{managed_section}\n");
fs::write(&claude_md_path, content)
.with_context(|| format!("failed to write {}", claude_md_path.display()))?;
writeln!(summary, "created .claude/CLAUDE.md (with managed section)").unwrap();
}
Ok(CommandOutcome::RawOutput(summary.trim_end().to_owned()))
}
fn minimal_toml_dir(dir_value: &str) -> String {
let table =
toml::map::Map::from_iter([("dir".to_owned(), TomlValue::String(dir_value.to_owned()))]);
toml::to_string(&table).unwrap_or_else(|_| {
format!(
"dir = \"{}\"\n",
dir_value.replace('\\', "\\\\").replace('"', "\\\"")
)
})
}
fn count_md_files_recursive(dir: &Path) -> usize {
let Ok(entries) = fs::read_dir(dir) else {
return 0;
};
let mut count = 0;
for entry in entries.flatten() {
let is_real_dir = entry
.file_type()
.is_ok_and(|ft| ft.is_dir() && !ft.is_symlink());
if is_real_dir {
count += count_md_files_recursive(&entry.path());
} else if entry
.path()
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
{
count += 1;
}
}
count
}
fn is_fuzzy_candidate(dir_name: &str) -> bool {
let lower = dir_name.to_ascii_lowercase();
CANDIDATE_DIRS.iter().any(|c| lower.contains(*c))
}
fn auto_detect_dir(cwd: &Path) -> String {
let mut best_dir: Option<String> = None;
let mut best_count = 0usize;
for candidate in CANDIDATE_DIRS {
let candidate_path = cwd.join(candidate);
if candidate_path.is_dir() {
let count = count_md_files_recursive(&candidate_path);
if count > best_count {
best_count = count;
best_dir = Some((*candidate).to_owned());
}
}
}
if let Ok(entries) = fs::read_dir(cwd) {
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
if !ft.is_dir() || ft.is_symlink() {
continue;
}
let path = entry.path();
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name.starts_with('.') || CANDIDATE_DIRS.contains(&dir_name) {
continue;
}
if is_fuzzy_candidate(dir_name) {
let count = count_md_files_recursive(&path);
if count > best_count {
best_count = count;
best_dir = Some(dir_name.to_owned());
}
}
}
}
let root_count = count_md_root_only(cwd);
if root_count > best_count {
return ".".to_owned();
}
best_dir.unwrap_or_else(|| ".".to_owned())
}
fn count_md_root_only(dir: &Path) -> usize {
let Ok(entries) = fs::read_dir(dir) else {
return 0;
};
let mut count = 0;
for entry in entries.flatten() {
let is_real_dir = entry
.file_type()
.is_ok_and(|ft| ft.is_dir() && !ft.is_symlink());
if is_real_dir {
let path = entry.path();
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !dir_name.starts_with('.') && !is_fuzzy_candidate(dir_name) {
count += count_md_files_recursive(&path);
}
} else if entry
.path()
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("md"))
{
count += 1;
}
}
count
}
const DIR_SENTINEL: &str = "hyalo-knowledgebase";
fn parameterize_template(template: &str, dir: &str) -> String {
let normalised = dir.replace('\\', "/");
let trimmed = normalised.trim_end_matches('/');
let clean = if trimmed.is_empty() { "." } else { trimmed };
let escaped = clean.replace('"', "\\\"");
template.replace(DIR_SENTINEL, &escaped)
}
fn parameterize_rule(template: &str, dir: &str) -> String {
let sentinel_glob = format!("{DIR_SENTINEL}/**");
assert!(
template.contains(&sentinel_glob),
"rule template does not contain the expected sentinel {sentinel_glob:?}"
);
parameterize_template(template, dir)
}
fn append_line_to_file_content(content: &str, line: &str) -> String {
let mut result = content.trim_end_matches('\n').to_owned();
result.push('\n');
result.push('\n');
result.push_str(line);
result.push('\n');
result
}
fn upsert_managed_section(content: &str, section: &str) -> (String, &'static str) {
let lines: Vec<&str> = content.lines().collect();
let start_idx = lines.iter().position(|l| l.contains(SECTION_START));
let end_idx = lines.iter().position(|l| l.contains(SECTION_END));
if let (Some(s), Some(e)) = (start_idx, end_idx)
&& s < e
{
let mut result = String::new();
for line in &lines[..s] {
result.push_str(line);
result.push('\n');
}
result.push_str(section);
result.push('\n');
for line in &lines[e + 1..] {
result.push_str(line);
result.push('\n');
}
return (result, "replaced managed section");
}
let appended = append_line_to_file_content(content, section);
(appended, "appended managed section")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn append_adds_blank_line_separator() {
let content = "# Existing\n\nSome content.\n";
let result = append_line_to_file_content(content, "New hint");
assert_eq!(result, "# Existing\n\nSome content.\n\nNew hint\n");
}
#[test]
fn append_handles_trailing_newlines() {
let content = "# Existing\n\n";
let result = append_line_to_file_content(content, "New hint");
assert_eq!(result, "# Existing\n\nNew hint\n");
}
#[test]
fn append_handles_empty_content() {
let result = append_line_to_file_content("", "New hint");
assert_eq!(result, "\n\nNew hint\n");
}
#[test]
fn count_md_files_recursive_counts_nested() {
let tmp = tempfile::TempDir::new().unwrap();
assert_eq!(count_md_files_recursive(tmp.path()), 0);
fs::write(tmp.path().join("top.md"), "# Top").unwrap();
let sub = tmp.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(sub.join("nested.md"), "# Nested").unwrap();
fs::write(sub.join("not.txt"), "text").unwrap();
assert_eq!(count_md_files_recursive(tmp.path()), 2);
}
#[test]
fn auto_detect_dir_falls_back_to_dot() {
let tmp = tempfile::TempDir::new().unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, ".");
}
#[test]
fn auto_detect_dir_picks_most_md_files() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("docs")).unwrap();
fs::write(tmp.path().join("docs").join("a.md"), "# A").unwrap();
fs::create_dir_all(tmp.path().join("knowledgebase").join("sub")).unwrap();
fs::write(tmp.path().join("knowledgebase").join("b.md"), "# B").unwrap();
fs::write(
tmp.path().join("knowledgebase").join("sub").join("c.md"),
"# C",
)
.unwrap();
fs::write(
tmp.path().join("knowledgebase").join("sub").join("d.md"),
"# D",
)
.unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "knowledgebase");
}
#[test]
fn auto_detect_dir_finds_docs() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("docs")).unwrap();
fs::write(tmp.path().join("docs").join("note.md"), "# Hello").unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "docs");
}
#[test]
fn auto_detect_dir_skips_empty_candidate() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("docs")).unwrap();
fs::create_dir_all(tmp.path().join("knowledgebase")).unwrap();
fs::write(tmp.path().join("knowledgebase").join("note.md"), "# Hello").unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "knowledgebase");
}
#[test]
fn auto_detect_dir_falls_back_to_dot_when_root_has_most() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("docs")).unwrap();
fs::write(tmp.path().join("docs").join("a.md"), "# A").unwrap();
fs::write(tmp.path().join("x.md"), "# X").unwrap();
fs::write(tmp.path().join("y.md"), "# Y").unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, ".");
}
#[test]
fn auto_detect_dir_fuzzy_matches_containing_candidate_substring() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("my-knowledgebase")).unwrap();
fs::write(tmp.path().join("my-knowledgebase").join("a.md"), "# A").unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "my-knowledgebase");
}
#[test]
fn auto_detect_dir_fuzzy_matches_project_wiki() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("project-wiki")).unwrap();
fs::write(
tmp.path().join("project-wiki").join("readme.md"),
"# Readme",
)
.unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "project-wiki");
}
#[test]
fn auto_detect_dir_fuzzy_does_not_double_count_in_root() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("my-knowledgebase")).unwrap();
fs::write(tmp.path().join("my-knowledgebase").join("a.md"), "# A").unwrap();
fs::write(tmp.path().join("my-knowledgebase").join("b.md"), "# B").unwrap();
fs::write(tmp.path().join("readme.md"), "# Root").unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "my-knowledgebase");
}
#[test]
fn auto_detect_dir_exact_candidate_beats_fuzzy_with_fewer_files() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("docs")).unwrap();
fs::write(tmp.path().join("docs").join("a.md"), "# A").unwrap();
fs::write(tmp.path().join("docs").join("b.md"), "# B").unwrap();
fs::create_dir_all(tmp.path().join("my-docs")).unwrap();
fs::write(tmp.path().join("my-docs").join("c.md"), "# C").unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "docs");
}
#[test]
fn parameterize_template_replaces_sentinel() {
let template = "Use hyalo-knowledgebase for all docs in hyalo-knowledgebase/\n";
let result = parameterize_template(template, "my-docs");
assert_eq!(result, "Use my-docs for all docs in my-docs/\n");
assert!(!result.contains("hyalo-knowledgebase"));
}
#[test]
fn parameterize_template_normalises_windows_backslashes() {
let template = "git log -- \"hyalo-knowledgebase/\"\n";
let result = parameterize_template(template, "my\\notes");
assert!(result.contains("my/notes"));
assert!(!result.contains('\\'));
}
#[test]
fn parameterize_template_escapes_double_quotes() {
let template = "path: hyalo-knowledgebase\n";
let result = parameterize_template(template, "my\"notes");
assert!(result.contains("my\\\"notes"));
}
#[test]
fn parameterize_template_strips_trailing_slash() {
let template = "git log -- \"hyalo-knowledgebase/\" | head\n";
let result = parameterize_template(template, "vault/");
assert!(result.contains("\"vault/\""));
assert!(!result.contains("//"));
}
#[test]
fn parameterize_template_trailing_slash_only_becomes_dot() {
let template = "hyalo-knowledgebase/**";
let result = parameterize_template(template, "/");
assert_eq!(result, "./**");
}
#[test]
fn parameterize_rule_replaces_path() {
let template = "---\npaths:\n - \"hyalo-knowledgebase/**\"\n---\nContent here.\n";
let result = parameterize_rule(template, "docs");
assert!(result.contains("\"docs/**\""));
assert!(!result.contains("hyalo-knowledgebase"));
}
#[test]
fn parameterize_rule_with_dot_dir() {
let template = "---\npaths:\n - \"hyalo-knowledgebase/**\"\n---\n";
let result = parameterize_rule(template, ".");
assert!(result.contains("\"./**\""));
}
#[test]
fn parameterize_rule_normalises_windows_backslashes() {
let template = "---\npaths:\n - \"hyalo-knowledgebase/**\"\n---\n";
let result = parameterize_rule(template, "my\\notes");
assert!(result.contains("my/notes/**"));
assert!(!result.contains('\\'));
}
#[test]
fn parameterize_rule_escapes_double_quotes_in_dir() {
let template = "---\npaths:\n - \"hyalo-knowledgebase/**\"\n---\n";
let result = parameterize_rule(template, "my\"notes");
assert!(result.contains("my\\\"notes/**"));
}
#[test]
fn count_md_root_only_skips_hidden_dirs() {
let tmp = tempfile::TempDir::new().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "# Hidden").unwrap();
fs::write(claude_dir.join("SKILL.md"), "# Skill").unwrap();
let other_dir = tmp.path().join("other");
fs::create_dir_all(&other_dir).unwrap();
fs::write(other_dir.join("note.md"), "# Note").unwrap();
fs::write(tmp.path().join("readme.md"), "# Root").unwrap();
let count = count_md_root_only(tmp.path());
assert_eq!(count, 2);
}
fn make_section() -> String {
format!("{SECTION_START}\n{CLAUDE_MD_HINT}\n{SECTION_END}")
}
#[test]
fn upsert_managed_section_appends_when_absent() {
let content = "# Existing\n\nSome content.\n";
let section = make_section();
let (result, action) = upsert_managed_section(content, §ion);
assert_eq!(action, "appended managed section");
assert!(
result.contains("Some content."),
"original content preserved"
);
assert!(result.contains(SECTION_START), "start marker present");
assert!(result.contains(SECTION_END), "end marker present");
assert!(result.contains(CLAUDE_MD_HINT), "hint content present");
let orig_pos = result.find("Some content.").unwrap();
let section_pos = result.find(SECTION_START).unwrap();
assert!(
section_pos > orig_pos,
"section appended after original content"
);
}
#[test]
fn upsert_managed_section_replaces_existing_markers() {
let section = make_section();
let old_content =
format!("# Before\n\n{SECTION_START}\nold hint text\n{SECTION_END}\n\n# After\n");
let (result, action) = upsert_managed_section(&old_content, §ion);
assert_eq!(action, "replaced managed section");
assert!(
result.contains("# Before"),
"content before markers preserved"
);
assert!(
result.contains("# After"),
"content after markers preserved"
);
assert!(result.contains(CLAUDE_MD_HINT), "new hint content present");
assert!(!result.contains("old hint text"), "old hint text replaced");
assert_eq!(result.matches(SECTION_START).count(), 1);
assert_eq!(result.matches(SECTION_END).count(), 1);
}
#[test]
fn upsert_managed_section_preserves_surrounding_content() {
let section = make_section();
let before = "# Top\n\nFirst paragraph.\n\n";
let after = "\n\n# Bottom\n\nLast paragraph.\n";
let old_content = format!("{before}{SECTION_START}\nstale\n{SECTION_END}{after}");
let (result, _action) = upsert_managed_section(&old_content, §ion);
assert!(
result.starts_with("# Top\n"),
"leading content preserved exactly"
);
assert!(
result.contains("First paragraph."),
"first paragraph preserved"
);
assert!(
result.contains("Last paragraph."),
"last paragraph preserved"
);
assert!(!result.contains("stale"), "stale content replaced");
}
#[test]
fn upsert_managed_section_handles_missing_end_marker() {
let section = make_section();
let content = format!("# Existing\n\n{SECTION_START}\norphaned start\n");
let (result, action) = upsert_managed_section(&content, §ion);
assert_eq!(action, "appended managed section");
assert!(result.contains(SECTION_END), "end marker now present");
assert!(result.contains(CLAUDE_MD_HINT), "hint content present");
}
#[test]
fn auto_detect_dir_ignores_hidden_dirs_in_root_count() {
let tmp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("docs")).unwrap();
fs::write(tmp.path().join("docs").join("a.md"), "# A").unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
fs::write(claude_dir.join("CLAUDE.md"), "#").unwrap();
fs::write(claude_dir.join("SKILL.md"), "#").unwrap();
fs::write(claude_dir.join("RULE.md"), "#").unwrap();
let result = auto_detect_dir(tmp.path());
assert_eq!(result, "docs");
}
#[test]
fn run_init_overwrites_skills_on_rerun() {
let tmp = tempfile::TempDir::new().unwrap();
let outcome1 = run_init_in(Some("docs"), true, tmp.path()).unwrap();
let CommandOutcome::RawOutput(out1) = outcome1 else {
panic!("expected success");
};
assert!(out1.contains("created .claude/skills/hyalo/SKILL.md"));
assert!(out1.contains("created .claude/skills/hyalo-tidy/SKILL.md"));
assert!(out1.contains("created .claude/rules/knowledgebase.md"));
let outcome2 = run_init_in(Some("docs"), true, tmp.path()).unwrap();
let CommandOutcome::RawOutput(out2) = outcome2 else {
panic!("expected success");
};
assert!(out2.contains("updated .claude/skills/hyalo/SKILL.md"));
assert!(out2.contains("updated .claude/skills/hyalo-tidy/SKILL.md"));
assert!(out2.contains("updated .claude/rules/knowledgebase.md"));
}
#[test]
fn run_init_updates_toml_when_dir_explicit() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join(".hyalo.toml"), "dir = \"old\"\n").unwrap();
let outcome = run_init_in(Some("newdir"), false, tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected success");
};
assert!(out.contains(".hyalo.toml"));
let content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(content.contains("dir = \"newdir\""));
}
#[test]
fn run_init_preserves_other_toml_keys_when_updating_dir() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(
tmp.path().join(".hyalo.toml"),
"dir = \"old\"\nformat = \"text\"\nhints = true\n",
)
.unwrap();
let outcome = run_init_in(Some("newdir"), false, tmp.path()).unwrap();
assert!(matches!(outcome, CommandOutcome::RawOutput(_)));
let content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert!(content.contains("dir = \"newdir\""));
assert!(content.contains("format = \"text\""));
assert!(content.contains("hints = true"));
}
#[test]
fn run_init_skips_toml_when_exists_and_no_explicit_dir() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join(".hyalo.toml"), "dir = \"old\"\n").unwrap();
let outcome = run_init_in(None, false, tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected success");
};
assert!(out.contains("skipped .hyalo.toml"));
let content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
assert_eq!(content, "dir = \"old\"\n");
}
#[test]
fn run_init_rule_uses_detected_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let outcome = run_init_in(Some("my-notes"), true, tmp.path()).unwrap();
assert!(matches!(outcome, CommandOutcome::RawOutput(_)));
let rule_content = fs::read_to_string(
tmp.path()
.join(".claude")
.join("rules")
.join("knowledgebase.md"),
)
.unwrap();
assert!(rule_content.contains("my-notes/**"));
assert!(!rule_content.contains("hyalo-knowledgebase/**"));
}
#[test]
fn upsert_managed_section_inverted_markers_treated_as_absent() {
let section = make_section();
let content = format!("# File\n\n{SECTION_END}\nsome text\n{SECTION_START}\n# After\n");
let (result, action) = upsert_managed_section(&content, §ion);
assert_eq!(
action, "appended managed section",
"inverted markers fall through to append"
);
assert!(result.contains("# File"), "leading content preserved");
assert!(result.contains("# After"), "trailing content preserved");
assert!(result.contains(CLAUDE_MD_HINT), "hint content present");
let orig_pos = result.find("# After").unwrap();
let section_pos = result.find(CLAUDE_MD_HINT).unwrap();
assert!(
section_pos > orig_pos,
"appended section follows original content"
);
}
#[test]
fn minimal_toml_dir_produces_valid_toml_for_unicode() {
let output = minimal_toml_dir("my\u{1F4C1}notes");
let parsed: toml::Table = toml::from_str(&output).expect("must be valid TOML");
assert_eq!(
parsed.get("dir").and_then(|v| v.as_str()),
Some("my\u{1F4C1}notes"),
"unicode round-trips correctly"
);
}
#[test]
fn minimal_toml_dir_produces_valid_toml_for_backslash() {
let output = minimal_toml_dir("C:\\Users\\me\\notes");
let parsed: toml::Table = toml::from_str(&output).expect("must be valid TOML");
assert_eq!(
parsed.get("dir").and_then(|v| v.as_str()),
Some("C:\\Users\\me\\notes"),
"backslashes round-trip correctly"
);
}
#[test]
#[cfg(unix)]
fn count_md_files_recursive_does_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::TempDir::new().unwrap();
let real_dir = tmp.path().join("real");
fs::create_dir_all(&real_dir).unwrap();
fs::write(real_dir.join("a.md"), "# A").unwrap();
let link = real_dir.join("loop");
symlink(tmp.path(), &link).unwrap();
let count = count_md_files_recursive(tmp.path());
assert_eq!(count, 1, "only the real file counted, symlink loop ignored");
}
#[test]
fn run_init_overwrites_malformed_toml_non_table() {
let tmp = tempfile::TempDir::new().unwrap();
fs::write(tmp.path().join(".hyalo.toml"), "\"just a string\"\n").unwrap();
let outcome = run_init_in(Some("docs"), false, tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected success");
};
assert!(
out.contains("warning .hyalo.toml was malformed"),
"malformed warning present"
);
let content = fs::read_to_string(tmp.path().join(".hyalo.toml")).unwrap();
let parsed: toml::Table =
toml::from_str(&content).expect("overwritten content must be valid TOML");
assert_eq!(
parsed.get("dir").and_then(|v| v.as_str()),
Some("docs"),
"dir key written correctly"
);
}
}