use std::path::Path;
use crate::assignee::git_identity;
use crate::board_store::BoardStore;
use crate::config::{Config, PlanningConfig, PlanningCycle, Team, starter_board};
use crate::error::{Result, TixError};
use crate::planning::Planner;
pub fn run(cwd: &Path) -> Result<()> {
run_with(cwd, None)
}
pub fn run_with(cwd: &Path, beads: Option<&str>) -> Result<()> {
let path = cwd.join("tix.toml");
if path.exists() {
return Err(TixError::Config(format!(
"{} already exists — refusing to overwrite (delete it first if you want to re-init)",
path.display()
)));
}
let mut config = Config::starter();
seed_planning(&mut config);
let mode = resolve_beads_mode(&config, beads)?;
config.beads.mode = mode;
seed_board(cwd)?;
write_init_config(&config, &path)?;
seed_keybindings(cwd)?;
seed_milestone(cwd)?;
seed_tags(cwd, &config)?;
seed_gitignore(cwd)?;
println!("Created {}", path.display());
apply_beads(cwd, &config, mode)?;
Ok(())
}
fn resolve_beads_mode(config: &Config, beads: Option<&str>) -> Result<crate::config::BeadsMode> {
use crate::config::BeadsMode;
use std::io::IsTerminal as _;
if let Some(key) = beads {
return BeadsMode::from_key(key).ok_or_else(|| {
TixError::InvalidInput(format!(
"unknown --beads `{key}` — use off, mirror, or sync"
))
});
}
if !std::io::stdin().is_terminal() {
return Ok(BeadsMode::Off);
}
let detection = crate::sync::beads::detect(&crate::sync::ProcessRunner, config);
if !detection.workspace {
if detection.available {
eprintln!("tix: {}", detection.detail);
}
return Ok(BeadsMode::Off);
}
eprintln!("tix: {}", detection.detail);
eprint!("tix: keep this board in step with beads? [off/mirror/sync] (off) ");
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(TixError::Io)?;
match line.trim() {
"" => Ok(BeadsMode::Off),
key => BeadsMode::from_key(key).ok_or_else(|| {
TixError::InvalidInput(format!(
"unknown beads mode `{key}` — use off, mirror, or sync"
))
}),
}
}
fn apply_beads(cwd: &Path, config: &Config, mode: crate::config::BeadsMode) -> Result<()> {
use crate::config::BeadsMode;
match mode {
BeadsMode::Off => Ok(()),
BeadsMode::Mirror => {
println!("beads: mirror recorded (read-only) — `tix beads status` reads the workspace");
Ok(())
}
BeadsMode::Sync => {
let beads_dir = cwd.join(".beads");
if !beads_dir.is_dir() {
return Err(TixError::Config(format!(
"beads sync needs a workspace here: {} does not exist — run `bd init` first",
beads_dir.display()
)));
}
let written = crate::sync::beads::install_hooks(cwd, &beads_dir, &config.beads.binary)?;
println!(
"beads: sync recorded; installed {} hook(s) in {}",
written.len(),
beads_dir.join("hooks").display()
);
Ok(())
}
}
}
const GITIGNORE_START: &str = "# tix:start";
const GITIGNORE_END: &str = "# tix:end";
fn seed_gitignore(cwd: &Path) -> Result<()> {
let path = cwd.join(".gitignore");
let block = format!(
"{GITIGNORE_START}\n.tix-backups/\n**/.tix-numbers/\n*.tixbak\n*.md.bak\n{GITIGNORE_END}"
);
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let updated = crate::commands::upsert_block(&existing, &block, GITIGNORE_START, GITIGNORE_END);
if updated == existing {
return Ok(());
}
crate::commands::atomic_write(&path, &updated)?;
println!("Updated {}", path.display());
Ok(())
}
fn seed_board(cwd: &Path) -> Result<()> {
let store = BoardStore::new(cwd);
if store.dir().join("default.md").exists() {
return Ok(());
}
store.save(
"default",
&starter_board(),
Some("Only cards whose ordering differs from the default are listed in `card_order`."),
)
}
fn seed_tags(cwd: &Path, config: &Config) -> Result<()> {
let dir = cwd.join(config.tags_dir());
if !dir.exists() {
std::fs::create_dir_all(&dir)?;
println!("Created {}", dir.display());
}
Ok(())
}
fn write_init_config(config: &Config, path: &Path) -> Result<()> {
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => Path::new(".").to_path_buf(),
};
std::fs::create_dir_all(&dir)?;
let mut disk = config.clone();
disk.kanban = crate::config::KanbanSection::default();
let mut contents = toml::to_string_pretty(&disk)
.map_err(|e| TixError::Config(format!("cannot serialize tix.toml: {e}")))?;
match contents.find("\n[") {
Some(index) => contents.insert_str(index + 1, "tips_on_startup = true\n\n"),
None => contents.push_str("\ntips_on_startup = true\n"),
}
contents.push_str(
"\n# Optional new-issue templates for the F4 New Issue mode; selecting one\n\
# pre-fills the form. Every field is optional; the title stays required.\n\
# See docs/for-users/new-issue.md.\n\
# [templates.bug]\n\
# labels = [\"bug\"]\n\
# body = \"## Steps to reproduce\\n\\n## Expected\\n\"\n",
);
contents.push_str(
"\n# Frontmatter keys this project uses on purpose. An undeclared key is\n\
# preserved on write but `tix lint` warns about it (usually a typo);\n\
# declaring it here once stops the warning. Top-level names only.\n\
# [custom_attributes]\n\
# keys = [\"build_id\", \"reviewed_by\"]\n",
);
contents.push_str(
"\n# Configurable on-disk issue layout, relative to issues_dir. Directory\n\
# tokens: {assignee} {type} {status} {priority} {cycle} {milestone}.\n\
# Filename tokens: {id} {date} {slug} {number} {title}. Defaults shown.\n\
# `move_on_change` relocates a file when a templated value changes.\n\
# Inspect with `tix layout show`; relocate with `tix layout migrate`.\n\
# [layout]\n\
# directory = \"{assignee}/{type}/{status}\"\n\
# filename = \"{date}-{id}-{slug}.md\"\n\
# move_on_change = true\n",
);
contents.push_str(
"\n# Child repositories this project coordinates (tix #1529). A parent-only\n\
# list, read one level deep: each name is how the repository appears in\n\
# the F6 Repos mode, and each value is that child's project root (the\n\
# directory holding its tix.toml), relative to this file. Activating one\n\
# runs tix inside it; exiting returns here.\n\
# [repos]\n\
# tix-tracker = \"../tix\"\n\
# website = \"../website\"\n\
#\n\
# When this repository is itself a managed child, name the parent so the\n\
# F6 Repos mode can jump back to it.\n\
# parent_repo = \"../parent\"\n",
);
contents.push_str(
"\n# Per-type accent colors (tix #1530). Each shipped type already has a\n\
# default color; override any of them here. A type chip renders in its\n\
# color wherever it appears.\n\
# [type_colors]\n\
# bug = \"red\"\n\
# feature = \"green\"\n\
#\n\
# Tag colors live in `docs/tags/`, one `*.md` per tag with a `name:` and\n\
# an optional `color:`. See docs/key-bindings.md and the tags directory.\n",
);
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("tix.toml");
let tmp = dir.join(format!("{file_name}.tmp-{}", std::process::id()));
let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(contents.as_bytes())?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(TixError::Io(e));
}
Ok(())
}
fn seed_keybindings(cwd: &Path) -> Result<()> {
let path = cwd.join(crate::keybindings::KEYBINDINGS_FILE);
if path.exists() {
return Ok(());
}
let contents = "\
# Optional sparse TUI keybinding overrides. Every omitted mode and action keeps\n\
# its built-in chord. See docs/key-bindings.md for the complete reference.\n\
#\n\
# [board]\n\
# new_issue = [\"ctrl+n\"]\n";
crate::commands::atomic_write(&path, contents)?;
println!("Created {}", path.display());
Ok(())
}
fn seed_planning(config: &mut Config) {
config.milestones_dir = Some("docs/milestones".into());
config.planning = PlanningConfig {
default_cycle: Some(crate::cycle_store::DEFAULT_CYCLE_NAME.into()),
default_milestone: Some("Backlog".into()),
default_team: None,
..PlanningConfig::default()
};
config.cycles.insert(
crate::cycle_store::DEFAULT_CYCLE_NAME.into(),
PlanningCycle::default(),
);
if let Some(identity) = git_identity() {
config.teams.insert(
"default".into(),
Team {
members: vec![identity.to_string()],
},
);
config.planning.default_team = Some("default".into());
}
}
fn seed_milestone(cwd: &Path) -> Result<()> {
let planner = Planner::from_config(Config::default(), cwd.to_path_buf());
match planner.create_milestone_file(
"Backlog",
None,
Some("Default milestone for unscheduled work"),
) {
Ok(_) => Ok(()),
Err(TixError::InvalidInput(_)) => Ok(()),
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn seed_gitignore_creates_the_block_and_keeps_other_rules() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".gitignore");
fs::write(&path, "/target\nnode_modules\n").unwrap();
seed_gitignore(tmp.path()).unwrap();
let first = fs::read_to_string(&path).unwrap();
assert!(
first.contains(
"# tix:start\n.tix-backups/\n**/.tix-numbers/\n*.tixbak\n*.md.bak\n# tix:end"
),
"the marked block is written: {first}"
);
assert!(
first.contains("/target\nnode_modules"),
"unrelated rules are kept: {first}"
);
seed_gitignore(tmp.path()).unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), first);
}
#[test]
fn seed_gitignore_replaces_a_stale_block_in_place() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".gitignore");
fs::write(&path, "keep\n# tix:start\nold-rule\n# tix:end\ntail\n").unwrap();
seed_gitignore(tmp.path()).unwrap();
let got = fs::read_to_string(&path).unwrap();
assert!(
got.contains(
"# tix:start\n.tix-backups/\n**/.tix-numbers/\n*.tixbak\n*.md.bak\n# tix:end"
),
"the block is refreshed: {got}"
);
assert!(!got.contains("old-rule"), "the stale rule is gone: {got}");
assert!(
got.contains("keep") && got.contains("tail"),
"text around the block survives: {got}"
);
}
#[test]
fn init_needs_no_labels_key_because_decision_pending_is_builtin() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("tix.toml");
write_init_config(&Config::starter(), &path).unwrap();
let got = fs::read_to_string(&path).unwrap();
assert!(
!got.lines()
.any(|line| line.trim_start().starts_with("labels =")),
"`tix init` writes no labels key: {got}"
);
assert!(
Config::starter()
.labels()
.contains(&"decision-pending".to_string()),
"the builtin label resolves without any key"
);
}
}