#![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),
};
if dir_explicit && dir_value != "." {
let target = cwd.join(&dir_value);
if target.is_file() {
anyhow::bail!(
"--dir path '{}' is a file, not a directory",
target.display()
);
}
if !target.exists() {
fs::create_dir_all(&target)
.with_context(|| format!("failed to create directory {}", target.display()))?;
writeln!(summary, "created {dir_value}/").unwrap();
}
}
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()))
}
pub fn run_deinit() -> Result<CommandOutcome> {
let cwd = std::env::current_dir().context("failed to determine current working directory")?;
run_deinit_in(&cwd)
}
fn run_deinit_in(cwd: &Path) -> Result<CommandOutcome> {
let mut summary = String::new();
let skill_path = cwd
.join(".claude")
.join("skills")
.join("hyalo")
.join("SKILL.md");
remove_artifact(&skill_path, ".claude/skills/hyalo/SKILL.md", &mut summary)?;
let skill_dir = skill_path
.parent()
.context("skill path has no parent directory")?;
remove_dir_if_empty(skill_dir, ".claude/skills/hyalo/", &mut summary)?;
let tidy_skill_path = cwd
.join(".claude")
.join("skills")
.join("hyalo-tidy")
.join("SKILL.md");
remove_artifact(
&tidy_skill_path,
".claude/skills/hyalo-tidy/SKILL.md",
&mut summary,
)?;
let tidy_skill_dir = tidy_skill_path
.parent()
.context("tidy skill path has no parent directory")?;
remove_dir_if_empty(tidy_skill_dir, ".claude/skills/hyalo-tidy/", &mut summary)?;
let rules_path = cwd.join(".claude").join("rules").join("knowledgebase.md");
remove_artifact(&rules_path, ".claude/rules/knowledgebase.md", &mut summary)?;
let rules_dir = rules_path
.parent()
.context("rules path has no parent directory")?;
remove_dir_if_empty(rules_dir, ".claude/rules/", &mut summary)?;
let all_skills_dir = cwd.join(".claude").join("skills");
remove_dir_if_empty(&all_skills_dir, ".claude/skills/", &mut summary)?;
let claude_md_path = cwd.join(".claude").join("CLAUDE.md");
if claude_md_path.exists() {
let content = fs::read_to_string(&claude_md_path)
.with_context(|| format!("failed to read {}", claude_md_path.display()))?;
let (stripped, was_stripped) = strip_managed_section(&content);
if was_stripped {
if stripped.is_empty() {
fs::remove_file(&claude_md_path)
.with_context(|| format!("failed to remove {}", claude_md_path.display()))?;
writeln!(
summary,
"removed .claude/CLAUDE.md (empty after stripping)"
)
.unwrap();
} else {
fs::write(&claude_md_path, &stripped)
.with_context(|| format!("failed to write {}", claude_md_path.display()))?;
writeln!(
summary,
"updated .claude/CLAUDE.md (stripped managed section)"
)
.unwrap();
}
} else {
writeln!(summary, "skipped .claude/CLAUDE.md (no managed section)").unwrap();
}
} else {
writeln!(summary, "skipped .claude/CLAUDE.md (not found)").unwrap();
}
let claude_dir = cwd.join(".claude");
remove_dir_if_empty(&claude_dir, ".claude/", &mut summary)?;
let toml_path = cwd.join(".hyalo.toml");
remove_artifact(&toml_path, ".hyalo.toml", &mut summary)?;
Ok(CommandOutcome::RawOutput(summary.trim_end().to_owned()))
}
fn remove_artifact(path: &Path, label: &str, summary: &mut String) -> Result<bool> {
match fs::remove_file(path) {
Ok(()) => {
writeln!(summary, "removed {label}").unwrap();
Ok(true)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
writeln!(summary, "skipped {label} (not found)").unwrap();
Ok(false)
}
Err(e) => Err(e).with_context(|| format!("failed to remove {}", path.display())),
}
}
fn remove_dir_if_empty(dir: &Path, label: &str, summary: &mut String) -> Result<()> {
if dir.is_dir() {
let is_empty = fs::read_dir(dir)
.with_context(|| format!("failed to read directory {}", dir.display()))?
.next()
.is_none();
if is_empty {
fs::remove_dir(dir)
.with_context(|| format!("failed to remove directory {}", dir.display()))?;
writeln!(summary, "removed {label}").unwrap();
}
}
Ok(())
}
fn strip_managed_section(content: &str) -> (String, bool) {
let lines: Vec<&str> = content.lines().collect();
let start_idx = lines.iter().position(|l| l.contains(SECTION_START));
let end_idx = start_idx.and_then(|s| {
lines
.iter()
.skip(s + 1)
.position(|l| l.contains(SECTION_END))
.map(|rel| s + 1 + rel)
});
if let (Some(s), Some(e)) = (start_idx, end_idx) {
let mut result = String::new();
for line in &lines[..s] {
result.push_str(line);
result.push('\n');
}
for line in &lines[e + 1..] {
result.push_str(line);
result.push('\n');
}
let trimmed = result.trim_end_matches('\n').to_owned();
let final_content = if trimmed.is_empty() {
String::new()
} else {
format!("{trimmed}\n")
};
return (final_content, true);
}
(content.to_owned(), false)
}
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_creates_missing_dir_when_explicit() {
let tmp = tempfile::TempDir::new().unwrap();
let outcome = run_init_in(Some("my-new-docs"), false, tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected RawOutput");
};
assert!(
out.contains("created my-new-docs/"),
"summary mentions created dir"
);
assert!(
tmp.path().join("my-new-docs").is_dir(),
"directory was created"
);
}
#[test]
fn run_init_does_not_create_dir_when_auto_detected() {
let tmp = tempfile::TempDir::new().unwrap();
let outcome = run_init_in(None, false, tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected RawOutput");
};
assert!(
!out.contains("created ./"),
"no directory creation line for auto-detected"
);
}
#[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"
);
}
#[test]
fn strip_managed_section_removes_markers() {
let content = format!("{SECTION_START}\nsome hint\n{SECTION_END}\n");
let (result, stripped) = strip_managed_section(&content);
assert!(stripped, "should report stripped");
assert!(result.is_empty(), "content should be empty after stripping");
}
#[test]
fn strip_managed_section_preserves_surrounding() {
let content = format!("# Before\n\n{SECTION_START}\nhint\n{SECTION_END}\n\n# After\n");
let (result, stripped) = strip_managed_section(&content);
assert!(stripped, "should report stripped");
assert!(result.contains("# Before"), "before content preserved");
assert!(result.contains("# After"), "after content preserved");
assert!(!result.contains(SECTION_START), "start marker removed");
assert!(!result.contains(SECTION_END), "end marker removed");
assert!(!result.contains("hint"), "managed content removed");
}
#[test]
fn strip_managed_section_returns_false_when_no_markers() {
let content = "# Just a normal file\n\nNo managed section here.\n";
let (result, stripped) = strip_managed_section(content);
assert!(!stripped, "should not report stripped");
assert_eq!(result, content, "content unchanged");
}
#[test]
fn strip_managed_section_handles_only_managed_content() {
let content = format!("{SECTION_START}\nhint text\n{SECTION_END}\n");
let (result, stripped) = strip_managed_section(&content);
assert!(stripped, "should report stripped");
assert!(
result.is_empty(),
"nothing left after stripping entire content"
);
}
#[test]
fn run_deinit_removes_all_artifacts() {
let tmp = tempfile::TempDir::new().unwrap();
run_init_in(Some("docs"), true, tmp.path()).unwrap();
let outcome = run_deinit_in(tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected RawOutput");
};
assert!(out.contains("removed .claude/skills/hyalo/SKILL.md"));
assert!(out.contains("removed .claude/skills/hyalo-tidy/SKILL.md"));
assert!(out.contains("removed .claude/rules/knowledgebase.md"));
assert!(
out.contains("removed .claude/CLAUDE.md (empty after stripping)")
|| out.contains("updated .claude/CLAUDE.md (stripped managed section)")
);
assert!(out.contains("removed .hyalo.toml"));
assert!(!tmp.path().join(".hyalo.toml").exists());
assert!(
!tmp.path()
.join(".claude")
.join("skills")
.join("hyalo")
.join("SKILL.md")
.exists()
);
assert!(
!tmp.path()
.join(".claude")
.join("skills")
.join("hyalo-tidy")
.join("SKILL.md")
.exists()
);
assert!(
!tmp.path()
.join(".claude")
.join("rules")
.join("knowledgebase.md")
.exists()
);
}
#[test]
fn run_deinit_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
run_init_in(Some("docs"), true, tmp.path()).unwrap();
run_deinit_in(tmp.path()).unwrap();
let outcome = run_deinit_in(tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected RawOutput");
};
assert!(out.contains("skipped .claude/skills/hyalo/SKILL.md (not found)"));
assert!(out.contains("skipped .claude/skills/hyalo-tidy/SKILL.md (not found)"));
assert!(out.contains("skipped .claude/rules/knowledgebase.md (not found)"));
assert!(out.contains("skipped .hyalo.toml (not found)"));
}
#[test]
fn run_deinit_preserves_non_managed_claude_md() {
let tmp = tempfile::TempDir::new().unwrap();
let claude_dir = tmp.path().join(".claude");
fs::create_dir_all(&claude_dir).unwrap();
let content = format!(
"# My custom instructions\n\nDo not delete me.\n\n{SECTION_START}\nhint\n{SECTION_END}\n"
);
fs::write(claude_dir.join("CLAUDE.md"), &content).unwrap();
let outcome = run_deinit_in(tmp.path()).unwrap();
let CommandOutcome::RawOutput(out) = outcome else {
panic!("expected RawOutput");
};
assert!(out.contains("updated .claude/CLAUDE.md (stripped managed section)"));
let remaining = fs::read_to_string(claude_dir.join("CLAUDE.md")).unwrap();
assert!(
remaining.contains("My custom instructions"),
"user content preserved"
);
assert!(
remaining.contains("Do not delete me."),
"user content preserved"
);
assert!(
!remaining.contains(SECTION_START),
"managed section removed"
);
assert!(!remaining.contains(SECTION_END), "managed section removed");
}
}