use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::{Args, ValueEnum};
use super::init_gitignore;
use super::init_rules::{self, BlockSections, Capability};
use super::init_settings;
use crate::config;
pub(crate) const BEGIN_MARKER: &str = "<!-- BEGIN basemind (managed by `basemind init`) -->";
pub(crate) const END_MARKER: &str = "<!-- END basemind -->";
pub(crate) const INIT_SCAFFOLD_TOML: &str = r##"# basemind configuration — https://github.com/Goldziher/basemind
# Lives at the repo root, or under `.config/` (`.config/basemind.toml` / `.config/basemind/
# config.toml`), and is meant to be committed. The index (blobs + Fjall) is derived state
# kept in the machine-global cache under ~/.local/share/basemind/ (override BASEMIND_DATA_HOME),
# keyed by workspace and wiped on schema bumps — nothing is written into the repo, so there is
# nothing to gitignore. Never put durable config in the cache.
# Every value below is the built-in default; uncomment and edit only what you want to change.
"$schema" = "v1"
[scan]
# Files to index. Default is "everything"; the tree-sitter language registry + a binary/size check
# filter the long tail. Narrow it if you only care about specific languages. Must not be empty.
# Globs are repo-relative, forward-slash, case-sensitive; `*` also crosses `/`. A bare name with no
# glob characters (`src`, `docs/api`) matches that path and everything beneath it, like .gitignore.
# include = ["**/*"]
# Extra exclude globs, ADDED ON TOP of the always-on floor (node_modules, target, dist, build, out,
# vendor, coverage, venv, .venv, __pycache__, .git, .basemind, bazel-*, .idea, .DS_Store, …). Same
# syntax as include: `generated` excludes every directory of that name; exclude beats include.
# The floor also drops credentials and key material so no secret becomes searchable: .env and
# .env.* (which includes .env.example), .aws, .ssh, .gnupg, .npmrc, .pypirc, .netrc,
# .git-credentials, id_rsa / id_dsa / id_ecdsa / id_ed25519, *.pem, *.key, *.p12, *.pfx, *.jks,
# *.keystore.
# exclude = []
# Floor entries to drop so that tree gets indexed, by directory name, file name or glob (`build`,
# `.env.*`, `*.pem`), or the floor pattern itself. `.git` and `.basemind` can never be allowed, and
# a credential entry is honoured only with the operator's BASEMIND_ALLOW_REPO_CREDENTIALS grant. The
# default exclude also lists dist/target/node_modules/.venv, so remove those from exclude as well
# when allowing them.
# floor_allow = []
# Honor .gitignore / .git/info/exclude while walking. Leave on unless you deliberately want
# ignored files indexed.
# respect_gitignore = true
# Follow symlinks during the walk. Off by default — symlinks often escape the repo (e.g. Bazel's
# bazel-* convenience symlinks). Turn on for repos that symlink real source into place. Ignored in
# this file unless the operator sets BASEMIND_ALLOW_FOLLOW_SYMLINKS=1 in the environment.
# follow_symlinks = false
# Skip files larger than this many bytes (prevents minified-bundle stalls).
# max_file_bytes = 2097152
# Skip paths under any submodule root listed in .gitmodules.
# skip_submodules = true
# Run L2 extraction (calls + docs) inline with L1. Powers the `code` tool's references / callers
# modes. Turning off roughly halves scan time on large repos but leaves reference search empty
# until an L2 pass.
# eager_l2 = true
# Absolute paths OUTSIDE the repo to also index (e.g. a Bazel external cache). Ignored unless the
# operator sets BASEMIND_ALLOW_EXTRA_ROOTS in the environment (1 = any workspace, or a `:`-separated
# list of absolute workspace roots), because this file is authored by the repository. Credential
# directories (.ssh, .aws, .gnupg, /etc) are refused even then. Symlinks inside them are followed
# only when scan.follow_symlinks is on.
# extra_roots = []
# Per-grammar overrides, keyed by tree-sitter-language-pack name (`basemind lang list`).
# enabled = false: stop parsing that grammar's files as code; they fall through to the document
# tier like any unrecognised file (use [documents] exclude to drop them entirely). Fixes
# misdetections such as `.txt` (vimdoc) and `.conf` (nginx).
# extensions / filenames: map extra paths onto the grammar (override built-in detection).
# preload = true: `basemind lang install` also fetches this grammar.
# [languages.vimdoc]
# enabled = false
# [languages.jinja2]
# extensions = [".mako", ".tpl"]
# filenames = ["BUILD.in"]
# preload = true
[code_intel]
# Precise, scope- and import-aware name resolution. On by default: JS/TS resolve via oxc, Python and
# Java via the stack-graphs engine, so the `code` tool's references / callers / definition modes
# distinguish a shadowed local from an import instead of matching by name. Set false to fall back to fast
# tree-sitter locals binding for every language. Applies to files (re)scanned after the change.
# precise_resolution = true
[watch]
# Coalesce filesystem events within this window (milliseconds).
# debounce_ms = 250
# Reserved: parsed but has no effect yet.
# live_l2 = false
[cache]
# Reserved: parsed but has no effect yet (see [resources] max_map_cache_mb for the outline cache).
# file_map_lru = 256
[mcp]
# Reserved: "stdio" is the only transport and nothing reads this key.
# transport = "stdio"
[documents]
# Document RAG tier (PDF / Office / HTML / email / images). Requires a `documents` build.
# enabled = true
# Embed documents for semantic search. ON by default — embeddings pay off on real prose / OCR.
# embed = true
# Embedding model preset. Changing it forces a FULL RE-EMBED of the corpus (time + CPU): every
# document is re-encoded at the new model's dimension.
# fast — smallest / fastest, lowest quality
# balanced — default; 768-dim, good quality/cost tradeoff
# quality — larger model, best English quality, slower
# multilingual— multilingual model for non-English corpora
# embedding_preset = "balanced"
# Globs scoping which non-code files are indexed as documents. Empty include = everything that
# passes [scan]; exclude applies to document indexing itself and beats include.
# include = []
# exclude = []
# Per-document size cap in bytes (separate from scan.max_file_bytes, so big PDFs/Office files work).
# max_file_bytes = 52428800
# Extensions to skip on top of the built-in archive/binary floor (case-insensitive, dot optional).
# extension_denylist = []
# Embedding scope: when embed_include is non-empty only matching documents are embedded; embed_exclude
# always wins. Non-embedded documents stay extracted + keyword-searchable. Changing either (or embed)
# removes the vectors of newly ineligible documents on the next scan.
# embed_include = []
# embed_exclude = []
# Route archives (.zip/.tar/.jar/…) into the recursive archive extractor. Off by default so one
# archive can't explode into thousands of embeds. True binaries are always skipped. In the shared
# daemon this also needs BASEMIND_DAEMON_ALLOW_EXTRACT_ARCHIVES=1 in the daemon's environment.
# extract_archives = false
[code_search]
# Semantic code-search tier. Requires a `code-search` build.
# enabled = true
# Embed source code for VECTOR search. OFF by default — local embeddings on code aren't worth the
# cost (code is embedded with a general English model, and NL→symbol is already served by the BM25
# keyword lane over the same text). Chunking + BM25 keyword search work regardless. Turn on only if
# you specifically want vector search over code (downloads an ONNX model, re-embeds on preset change).
# embed = false
# Embedding scope for source files (only used when embed = true): embed_include is an allow-list
# (empty = all chunked files), embed_exclude wins over it; files left out are still chunked +
# BM25-indexed. Changing either removes the vectors of newly ineligible files on the next scan.
# embed_include = []
# embed_exclude = []
[resources]
# Footprint bounds. 0 = auto for the thread / concurrency caps. The shared daemon treats these as
# ceilings: min(value here, daemon cap), with 0 / "auto" / "off" resolving to the cap. Raise a
# daemon cap with BASEMIND_DAEMON_MAX_SCAN_THREADS / _EMBED_THREADS / _EMBED_BATCH /
# _CONCURRENT_DOCUMENTS / _FOOTPRINT_MB / _MAP_CACHE_MB / _CANDIDATES in the daemon's environment
# (the daemon also caps file, document, crawl and debounce settings; see the configuration docs).
# scan_threads = 0
# embed_threads = 0
# max_concurrent_documents = 0
# embed_batch_size = 32
# max_footprint_mb = 0
# max_map_cache_mb = 256
[crawl]
# Web crawl / scrape limits. allow_private_network = true is ignored when it comes from this file
# unless the operator sets BASEMIND_ALLOW_PRIVATE_HOSTS=1 in the environment.
# respect_robots_txt = true
# allow_private_network = false
[llm]
# Optional LLM for abstractive summaries and LLM NER. Inert while `model` is empty. In this file
# `base_url` is ignored and api_key env references are limited to the provider's standard variable
# (OPENAI_API_KEY for openai/..., ANTHROPIC_API_KEY for anthropic/..., or BASEMIND_LLM_API_KEY)
# unless the operator sets BASEMIND_ALLOW_REPO_LLM=1 in the environment.
# model = "openai/gpt-4o"
# api_key = { env = "OPENAI_API_KEY" }
"##;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
pub enum RulesTarget {
#[default]
Auto,
Claude,
ClaudeLocal,
Agents,
AgentsLocal,
AiRulez,
AiRulezLocal,
None,
}
fn capability_slugs() -> clap::builder::PossibleValuesParser {
clap::builder::PossibleValuesParser::new(Capability::ALL.map(Capability::slug))
}
#[derive(Args, Debug, Default)]
pub struct InitArgs {
#[arg(long)]
pub yes: bool,
#[arg(long = "with", value_name = "CAPABILITY", value_parser = capability_slugs())]
pub with: Vec<String>,
#[arg(long = "without", value_name = "CAPABILITY", value_parser = capability_slugs())]
pub without: Vec<String>,
#[arg(long, value_enum, default_value_t = RulesTarget::Auto)]
pub rules_target: RulesTarget,
#[arg(long, value_enum)]
pub settings_target: Option<init_settings::SettingsTarget>,
#[arg(long)]
pub no_rules: bool,
#[arg(long)]
pub no_usage_rules: bool,
#[arg(long)]
pub no_setup_notes: bool,
#[arg(long)]
pub print: bool,
#[arg(long, value_name = "DIR")]
pub config_dir: Option<String>,
}
pub(crate) enum Change {
Write {
path: PathBuf,
note: &'static str,
contents: String,
},
NoOp { note: String },
}
pub fn run(root: &Path, args: &InitArgs) -> Result<()> {
let caps = select_capabilities(args)?;
let sections = BlockSections {
usage_rules: !args.no_usage_rules,
setup_notes: !args.no_setup_notes,
};
let rules_target = resolve_rules_target(root, args)?;
let rules_plan = resolve_rules_plan(root, rules_target, args.no_rules);
let settings_target = init_settings::resolve_settings_target(args)?;
let settings_plan = init_settings::resolve_settings_plan(root, settings_target);
let mut changes = Vec::new();
changes.push(plan_config(root, args.config_dir.as_deref())?);
if let Some(rule_change) = plan_rules_change(&rules_plan, &caps, sections)? {
changes.push(rule_change);
}
if let Some(settings_change) = init_settings::plan_settings_change(&settings_plan)? {
changes.push(settings_change);
}
let gitignore_targets = gitignore_targets(&rules_plan, &settings_plan);
if args.print {
report_dry_run(&changes);
for target in &gitignore_targets {
if let Some(pattern) = init_gitignore::local_pattern(root, target)
&& !init_gitignore::is_covered(root, target)?
{
println!("would add {pattern:?} to .gitignore ({})", target.display());
}
}
return Ok(());
}
for target in &gitignore_targets {
if let Some(pattern) = init_gitignore::local_pattern(root, target)
&& let Some(gitignore_path) = init_gitignore::ensure_coverage(root, target, &pattern, args.yes)?
{
println!("added {pattern:?} to .gitignore: {}", gitignore_path.display());
}
}
let mut any_write = false;
for change in &changes {
match change {
Change::Write { path, note, contents } => {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
std::fs::write(path, contents).with_context(|| format!("write {}", path.display()))?;
println!("{note}: {}", path.display());
any_write = true;
}
Change::NoOp { note } => println!("{note}"),
}
}
if any_write {
println!("basemind init: done.");
} else {
println!("basemind init: nothing to do — already up to date.");
}
Ok(())
}
fn gitignore_targets(rules_plan: &RulesPlan, settings_plan: &init_settings::SettingsPlan) -> Vec<PathBuf> {
let mut targets = Vec::new();
if let Some(path) = rules_plan.target_path() {
targets.push(path.to_path_buf());
}
if let init_settings::SettingsPlan::Write(path) = settings_plan {
targets.push(path.clone());
}
targets
}
fn resolve_rules_target(root: &Path, args: &InitArgs) -> Result<RulesTarget> {
if args.no_rules {
return Ok(RulesTarget::None);
}
if args.rules_target != RulesTarget::Auto {
return Ok(args.rules_target);
}
if root.join(".ai-rulez").join("config.toml").exists() {
return Ok(RulesTarget::AiRulezLocal);
}
if !args.yes && std::io::stdin().is_terminal() {
return prompt_rules_target();
}
Ok(RulesTarget::Auto)
}
fn prompt_rules_target() -> Result<RulesTarget> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
println!("Where should the basemind rules block go?");
println!(" 1) CLAUDE.local.md — personal, gitignored (recommended)");
println!(" 2) CLAUDE.md — committed, shared with everyone on the repo");
println!(" 3) AGENTS.local.md — personal, gitignored");
println!(" 4) AGENTS.md — committed, shared with everyone on the repo");
println!(" 5) none — write no rules");
write!(stdout, "Choose [1-5, blank = 1]: ").context("write prompt")?;
stdout.flush().context("flush prompt")?;
let mut line = String::new();
stdin.read_line(&mut line).context("read stdin")?;
Ok(match line.trim() {
"" | "1" => RulesTarget::ClaudeLocal,
"2" => RulesTarget::Claude,
"3" => RulesTarget::AgentsLocal,
"4" => RulesTarget::Agents,
"5" => RulesTarget::None,
_ => RulesTarget::ClaudeLocal,
})
}
fn select_capabilities(args: &InitArgs) -> Result<Vec<Capability>> {
let with = parse_capabilities(&args.with)?;
let without = parse_capabilities(&args.without)?;
let base: Vec<Capability> = if with.is_empty() {
Capability::ALL.to_vec()
} else {
with.clone()
};
let interactive = !args.yes && with.is_empty() && without.is_empty() && std::io::stdin().is_terminal();
let selected: Vec<Capability> = if interactive {
prompt_capabilities()?
} else {
base.into_iter().filter(|c| !without.contains(c)).collect()
};
Ok(selected)
}
fn parse_capabilities(slugs: &[String]) -> Result<Vec<Capability>> {
slugs
.iter()
.map(|s| {
Capability::from_slug(s).ok_or_else(|| {
anyhow::anyhow!(
"unknown capability {s:?}; expected one of: {}",
Capability::ALL.map(|c| c.slug()).join(", ")
)
})
})
.collect()
}
fn prompt_capabilities() -> Result<Vec<Capability>> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
println!("Select basemind capabilities to advertise (Y/n, blank = yes):");
let mut selected = Vec::new();
for cap in Capability::ALL {
write!(stdout, " {} [Y/n] ", cap.label()).context("write prompt")?;
stdout.flush().context("flush prompt")?;
let mut line = String::new();
let read = stdin.read_line(&mut line).context("read stdin")?;
let answer = line.trim().to_ascii_lowercase();
let yes = answer.is_empty() || answer == "y" || answer == "yes";
if yes {
selected.push(cap);
}
if read == 0 {
break;
}
}
Ok(selected)
}
fn plan_config(root: &Path, config_dir: Option<&str>) -> Result<Change> {
let path = resolve_init_config_path(root, config_dir)?;
if path.exists() {
return Ok(Change::NoOp {
note: format!("basemind.toml: kept existing config at {}", path.display()),
});
}
let legacy = config::legacy_config_path(root);
if path == config::config_path(root) && legacy.exists() {
anyhow::bail!(
"legacy config at {} is still read as a fallback; move it to {} to migrate — init will \
not write a scaffold that shadows it",
legacy.display(),
path.display()
);
}
Ok(Change::Write {
path,
note: "wrote basemind.toml scaffold",
contents: INIT_SCAFFOLD_TOML.to_string(),
})
}
fn resolve_init_config_path(root: &Path, config_dir: Option<&str>) -> Result<PathBuf> {
let Some(dir) = config_dir.map(str::trim).filter(|dir| !dir.is_empty()) else {
return Ok(config::config_path(root));
};
let normalized = dir.trim_start_matches("./").trim_end_matches('/');
if normalized == config::CONFIG_CONVENTION_DIR {
return Ok(config::convention_flat_config_path(root));
}
if normalized == format!("{}/{}", config::CONFIG_CONVENTION_DIR, config::CONFIG_CONVENTION_SUBDIR) {
return Ok(config::convention_nested_config_path(root));
}
anyhow::bail!(
"unsupported --config-dir {dir:?}: basemind auto-discovers only the repo root, \
`.config/basemind.toml`, and `.config/basemind/config.toml` — pass `.config` or \
`.config/basemind`"
)
}
#[derive(Debug)]
enum RulesPlan {
Skip,
AiRulez(PathBuf),
Delimited(PathBuf),
}
fn resolve_rules_plan(root: &Path, target: RulesTarget, no_rules: bool) -> RulesPlan {
if no_rules || target == RulesTarget::None {
return RulesPlan::Skip;
}
let ai_rulez_rule = root.join(".ai-rulez").join("rules").join("basemind-usage.md");
let ai_rulez_local_rule = root
.join(".ai-rulez")
.join("local")
.join("rules")
.join("basemind-usage.md");
let claude = root.join("CLAUDE.md");
let claude_local = root.join("CLAUDE.local.md");
let agents = root.join("AGENTS.md");
let agents_local = root.join("AGENTS.local.md");
match target {
RulesTarget::AiRulez => RulesPlan::AiRulez(ai_rulez_rule),
RulesTarget::AiRulezLocal => RulesPlan::AiRulez(ai_rulez_local_rule),
RulesTarget::Claude => RulesPlan::Delimited(claude),
RulesTarget::ClaudeLocal => RulesPlan::Delimited(claude_local),
RulesTarget::Agents => RulesPlan::Delimited(agents),
RulesTarget::AgentsLocal => RulesPlan::Delimited(agents_local),
RulesTarget::None => RulesPlan::Skip,
RulesTarget::Auto => {
if root.join(".ai-rulez").join("config.toml").exists() {
RulesPlan::AiRulez(ai_rulez_local_rule)
} else if agents.exists() && !claude.exists() {
RulesPlan::Delimited(agents_local)
} else {
RulesPlan::Delimited(claude_local)
}
}
}
}
impl RulesPlan {
fn target_path(&self) -> Option<&Path> {
match self {
RulesPlan::Skip => None,
RulesPlan::AiRulez(path) | RulesPlan::Delimited(path) => Some(path),
}
}
}
fn plan_rules_change(plan: &RulesPlan, caps: &[Capability], sections: BlockSections) -> Result<Option<Change>> {
match plan {
RulesPlan::Skip => Ok(Some(Change::NoOp {
note: "rules: skipped (--no-rules / --rules-target none)".to_string(),
})),
RulesPlan::AiRulez(path) => {
let contents = init_rules::render_ai_rulez_rule(caps, sections);
let unchanged = std::fs::read_to_string(path).is_ok_and(|prev| prev == contents);
if unchanged {
return Ok(Some(Change::NoOp {
note: format!("rules: ai-rulez rule already up to date ({})", path.display()),
}));
}
Ok(Some(Change::Write {
path: path.clone(),
note: "wrote ai-rulez rule (run `ai-rulez generate` to render outputs)",
contents,
}))
}
RulesPlan::Delimited(path) => {
let block = format!(
"{BEGIN_MARKER}\n\n{}{END_MARKER}\n",
init_rules::render_block_body(caps, sections)
);
let existing = match std::fs::read_to_string(path) {
Ok(c) => Some(c),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(anyhow::Error::new(e).context(format!("read {}", path.display()))),
};
let next = splice_block(existing.as_deref(), &block)
.with_context(|| format!("update basemind rules block in {}", path.display()))?;
if existing.as_deref() == Some(next.as_str()) {
return Ok(Some(Change::NoOp {
note: format!("rules: block already up to date ({})", path.display()),
}));
}
Ok(Some(Change::Write {
path: path.clone(),
note: "injected basemind rules block",
contents: next,
}))
}
}
}
fn splice_block(existing: Option<&str>, block: &str) -> Result<String> {
let Some(existing) = existing else {
return Ok(block.to_string());
};
match (existing.find(BEGIN_MARKER), existing.find(END_MARKER)) {
(Some(begin), Some(end_start)) => {
let begin_body = begin + BEGIN_MARKER.len();
if end_start < begin_body {
anyhow::bail!(
"malformed basemind block: END marker precedes BEGIN marker — resolve the markers manually then re-run"
);
}
if existing[begin_body..end_start].contains(BEGIN_MARKER) {
anyhow::bail!(
"malformed basemind block: a second BEGIN marker before the END marker — resolve the markers manually then re-run"
);
}
let end = end_start + END_MARKER.len();
let after = &existing[end..];
let tail_start = if after.starts_with("\r\n") {
end + 2
} else if after.starts_with('\n') {
end + 1
} else {
end
};
let mut out = String::with_capacity(existing.len() + block.len());
out.push_str(&existing[..begin]);
out.push_str(block);
out.push_str(&existing[tail_start..]);
Ok(out)
}
(Some(_), None) | (None, Some(_)) => anyhow::bail!(
"malformed basemind block: only one of the BEGIN/END markers is present — resolve the markers manually then re-run"
),
(None, None) => {
let mut out = existing.to_string();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(block);
Ok(out)
}
}
}
fn report_dry_run(changes: &[Change]) {
let mut pending = 0;
for change in changes {
match change {
Change::Write { path, note, .. } => {
println!("would {note}: {}", path.display());
pending += 1;
}
Change::NoOp { note } => println!("{note}"),
}
}
if pending == 0 {
println!("basemind init --print: no changes — already up to date.");
} else {
println!("basemind init --print: {pending} file(s) would change (nothing written).");
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[derive(Parser)]
struct Wrapper {
#[command(flatten)]
args: InitArgs,
}
#[test]
fn capability_flags_cover_every_capability_slug() {
let mut cmd = Wrapper::command();
let help = cmd.render_long_help().to_string();
for cap in Capability::ALL {
let slug = cap.slug();
assert!(help.contains(slug), "help omits `{slug}`");
let parsed = Wrapper::try_parse_from(["x", "--with", slug, "--without", slug]).expect("slug accepted");
assert_eq!(parsed.args.with, [slug]);
}
assert!(Wrapper::try_parse_from(["x", "--with", "bogus"]).is_err());
}
#[test]
fn scaffold_examples_parse_when_uncommented() {
let mut uncommented = String::new();
for line in INIT_SCAFFOLD_TOML.lines() {
let candidate = line.strip_prefix("# ").unwrap_or(line);
let is_example = line.starts_with("# ")
&& !candidate.starts_with(' ')
&& (candidate.starts_with('[')
|| toml::from_str::<toml::Table>(candidate).is_ok_and(|t| !t.is_empty()));
uncommented.push_str(if is_example { candidate } else { line });
uncommented.push('\n');
}
let cfg = config::parse_str(&uncommented).expect("uncommented scaffold parses and validates");
assert_eq!(
cfg.languages.len(),
2,
"language examples are live: {:?}",
cfg.languages
);
assert!(cfg.languages["jinja2"].preload);
assert!(!cfg.languages["vimdoc"].enabled);
assert_eq!(cfg.documents.max_file_bytes, 52_428_800);
assert!(cfg.scan.floor_allow.is_empty());
}
#[test]
fn splice_appends_block_when_no_markers() {
let out = splice_block(
Some("# Title\n\nbody\n"),
"<!-- BEGIN basemind (managed by `basemind init`) -->\nX\n<!-- END basemind -->\n",
)
.expect("well-formed input splices");
assert!(out.starts_with("# Title\n\nbody\n"), "user content preserved");
assert_eq!(out.matches(BEGIN_MARKER).count(), 1);
}
#[test]
fn splice_replaces_in_place_and_is_idempotent() {
let block = format!("{BEGIN_MARKER}\nv2\n{END_MARKER}\n");
let before = format!("intro\n\n{BEGIN_MARKER}\nv1\n{END_MARKER}\n\noutro\n");
let after = splice_block(Some(&before), &block).expect("well-formed input splices");
assert_eq!(after.matches(BEGIN_MARKER).count(), 1, "no duplicate block");
assert!(after.contains("v2") && !after.contains("v1"), "content replaced");
assert!(
after.contains("intro") && after.contains("outro"),
"surrounding content kept"
);
assert_eq!(splice_block(Some(&after), &block).expect("fixpoint"), after);
}
#[test]
fn splice_bails_on_orphaned_begin_marker() {
let block = format!("{BEGIN_MARKER}\nv2\n{END_MARKER}\n");
let orphaned = format!("intro\n{BEGIN_MARKER}\nv1\nno end here\noutro\n");
assert!(
splice_block(Some(&orphaned), &block).is_err(),
"orphaned BEGIN must bail"
);
}
#[test]
fn splice_bails_on_reversed_and_doubled_markers() {
let block = format!("{BEGIN_MARKER}\nv2\n{END_MARKER}\n");
let reversed = format!("{END_MARKER}\nstray\n{BEGIN_MARKER}\n");
assert!(
splice_block(Some(&reversed), &block).is_err(),
"reversed markers must bail"
);
let doubled = format!("{BEGIN_MARKER}\na\n{BEGIN_MARKER}\nb\n{END_MARKER}\n");
assert!(splice_block(Some(&doubled), &block).is_err(), "doubled BEGIN must bail");
}
#[test]
fn splice_converges_to_fixpoint_on_crlf_file() {
let block = format!("{BEGIN_MARKER}\nv2\n{END_MARKER}\n");
let crlf = format!("intro\r\n{BEGIN_MARKER}\r\nv1\r\n{END_MARKER}\r\noutro\r\n");
let once = splice_block(Some(&crlf), &block).expect("first splice");
let twice = splice_block(Some(&once), &block).expect("second splice");
assert_eq!(once, twice, "CRLF file must converge to a fixpoint");
assert_eq!(once.matches(BEGIN_MARKER).count(), 1, "single block");
assert!(once.contains("outro"), "trailing user content kept");
}
#[test]
fn none_target_skips_rules() {
assert!(matches!(
resolve_rules_plan(Path::new("/nonexistent"), RulesTarget::None, false),
RulesPlan::Skip
));
}
#[test]
fn auto_never_targets_a_committed_file() {
let dir = tempfile::tempdir().expect("tempdir");
match resolve_rules_plan(dir.path(), RulesTarget::Auto, false) {
RulesPlan::Delimited(path) => assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some("CLAUDE.local.md"),
"fresh repo Auto must land in CLAUDE.local.md, got {}",
path.display()
),
other => panic!("expected a delimited CLAUDE.local.md plan, got {other:?}"),
}
std::fs::write(dir.path().join("AGENTS.md"), "# Agents\n").expect("seed AGENTS.md");
match resolve_rules_plan(dir.path(), RulesTarget::Auto, false) {
RulesPlan::Delimited(path) => assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some("AGENTS.local.md"),
"an AGENTS.md-only repo must land in AGENTS.local.md, got {}",
path.display()
),
other => panic!("expected a delimited AGENTS.local.md plan, got {other:?}"),
}
}
#[test]
fn explicit_local_and_committed_targets_resolve_to_their_files() {
let root = Path::new("/nonexistent");
let name = |plan: RulesPlan| match plan {
RulesPlan::Delimited(path) => path.file_name().and_then(|n| n.to_str()).map(str::to_owned),
other => panic!("expected delimited plan, got {other:?}"),
};
assert_eq!(
name(resolve_rules_plan(root, RulesTarget::ClaudeLocal, false)).as_deref(),
Some("CLAUDE.local.md")
);
assert_eq!(
name(resolve_rules_plan(root, RulesTarget::Claude, false)).as_deref(),
Some("CLAUDE.md")
);
assert_eq!(
name(resolve_rules_plan(root, RulesTarget::AgentsLocal, false)).as_deref(),
Some("AGENTS.local.md")
);
assert_eq!(
name(resolve_rules_plan(root, RulesTarget::Agents, false)).as_deref(),
Some("AGENTS.md")
);
}
#[test]
fn auto_resolves_to_ai_rulez_local_when_ai_rulez_owns_governance() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join(".ai-rulez")).expect("mkdir .ai-rulez");
std::fs::write(dir.path().join(".ai-rulez").join("config.toml"), "").expect("seed config.toml");
match resolve_rules_plan(dir.path(), RulesTarget::Auto, false) {
RulesPlan::AiRulez(path) => assert_eq!(
path,
dir.path()
.join(".ai-rulez")
.join("local")
.join("rules")
.join("basemind-usage.md"),
"Auto with ai-rulez present must resolve to the local rule tree"
),
other => panic!("expected an AiRulez plan, got {other:?}"),
}
assert_eq!(
resolve_rules_target(dir.path(), &InitArgs::default()).expect("resolve target"),
RulesTarget::AiRulezLocal,
"resolve_rules_target must also route to AiRulezLocal, not the committed AiRulez"
);
}
#[test]
fn explicit_ai_rulez_and_ai_rulez_local_resolve_to_their_own_files() {
let root = Path::new("/nonexistent");
match resolve_rules_plan(root, RulesTarget::AiRulez, false) {
RulesPlan::AiRulez(path) => {
assert_eq!(path, root.join(".ai-rulez").join("rules").join("basemind-usage.md"))
}
other => panic!("expected an AiRulez plan, got {other:?}"),
}
match resolve_rules_plan(root, RulesTarget::AiRulezLocal, false) {
RulesPlan::AiRulez(path) => assert_eq!(
path,
root.join(".ai-rulez")
.join("local")
.join("rules")
.join("basemind-usage.md")
),
other => panic!("expected an AiRulez plan, got {other:?}"),
}
}
}