use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use fairlead_core::config::{self, Config, ConfigError, LoadOptions, Loaded};
use fairlead_lang::tree::Tree;
use serde_json::json;
use crate::setup_items::{self, Item, State};
use crate::setup_track::{self, Track};
use crate::{agents_cmd, hooks_cmd, init_cmd, init_guard, setup_prove};
#[derive(clap::Args)]
pub struct SetupArgs {
#[arg(long, conflicts_with = "dry_run")]
check: bool,
#[arg(long)]
dry_run: bool,
#[arg(long, value_enum, value_name = "NAME")]
track: Option<Track>,
#[arg(long)]
json: bool,
}
struct Seen {
loaded: Result<Loaded, ConfigError>,
root: PathBuf,
repo: PathBuf,
tree: Tree,
}
impl Seen {
fn read(cwd: &Path) -> Seen {
let loaded = config::load(cwd, &LoadOptions::from_process(Vec::new()));
let root = setup_items::root_of(cwd, loaded.as_ref().ok());
Seen {
tree: Tree::scan(&root),
repo: crate::graph_cmd::repo_root(&root),
root,
loaded,
}
}
fn loaded(&self) -> Option<&Loaded> {
self.loaded.as_ref().ok()
}
fn config(&self) -> Option<&Config> {
self.loaded().map(|l| &l.config)
}
fn unwritten(&self) -> bool {
self.loaded().is_some_and(|l| l.files.is_empty())
&& !self.root.join("fairlead.toml").exists()
}
fn items(&self, track: Track) -> Vec<Item> {
setup_items::check_in(&self.tree, self.loaded(), track)
}
}
fn is_open(items: &[Item], id: &str) -> bool {
items.iter().any(|i| i.id == id && i.state == State::Open)
}
pub fn run(args: SetupArgs, cwd: &Path) -> ExitCode {
let seen = Seen::read(cwd);
if fairlead_guard::git::git_dir(&seen.repo).is_none() {
eprintln!("setup: not inside a git repository; run `git init` first");
return ExitCode::from(2);
}
let detected = setup_track::detect(&seen.root, &seen.tree.files, seen.config());
let (track, why) = match args.track {
Some(asked) => (
asked,
format!(
"asked for with --track; detected {}: {}",
detected.track.name(),
detected.why
),
),
None => (detected.track, detected.why),
};
let before = seen.items(track);
let mut would = Vec::new();
let items = if args.check {
before
} else if args.dry_run {
would = plan(&seen, &before);
before
} else {
apply(cwd, seen, before, track)
};
let (open, _) = setup_items::open_of(&items);
if args.json {
let mut out = json!({
"track": { "name": track.name(), "why": why },
"items": items,
"open": open,
});
if args.dry_run {
out["would"] = would
.iter()
.map(|(id, text)| json!({ "id": id, "text": text }))
.collect();
}
println!(
"{}",
serde_json::to_string_pretty(&out).expect("the checklist serializes")
);
} else if args.dry_run {
print!("{}", render_plan(track, &why, &would));
} else {
print!("{}", render(track, &why, &items));
}
if setup_items::blocked(&items) {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn row(word: &str, id: &str, text: &str) -> String {
format!(" {word:<7}{id:<11}{text}\n")
}
fn header(command: &str, track: Track, why: &str) -> String {
format!("{command} track: {} ({why})\n", track.name())
}
fn render(track: Track, why: &str, items: &[Item]) -> String {
let mut out = header("fairlead setup", track, why);
for item in items {
out.push_str(&row(item.state.word(), item.id, &item.text));
if let (State::Open, Some(fix)) = (item.state, &item.fix) {
out.push_str(&format!(" fix: {fix}\n"));
}
}
let shows = "`fairlead setup --check` shows this list any time.";
out.push_str(&match setup_items::open_of(items).0 {
0 => format!("Nothing open. {shows}\n"),
1 => format!("1 open. Fix it and run `fairlead setup` again. {shows}\n"),
n => format!("{n} open. Fix them and run `fairlead setup` again. {shows}\n"),
});
out
}
fn render_plan(track: Track, why: &str, would: &[(&'static str, String)]) -> String {
let mut out = header("fairlead setup --dry-run", track, why);
for (id, text) in would {
out.push_str(&row("would", id, text));
}
out.push_str(match would.is_empty() {
true => "Nothing to change. `fairlead setup --check` shows the checklist.\n",
false => "Nothing was changed. `fairlead setup` applies these.\n",
});
out
}
fn plan(seen: &Seen, items: &[Item]) -> Vec<(&'static str, String)> {
let mut would = Vec::new();
let mut full = seen.config().is_some_and(|c| c.profile.is_full());
let writes = is_open(items, "config") && seen.unwritten();
if writes {
let (_, guard) = init_cmd::plain(&seen.root, &seen.tree.files);
full = !guard;
let runners = init_cmd::runners_in(&seen.root, &seen.tree.files);
let text = match (guard, runners.as_slice()) {
(true, _) => "the guard only: init finds no test runner here".to_string(),
(false, []) => "the checks init finds, and no test runner".to_string(),
(false, ids) => format!("the runners init finds: {}", ids.join(", ")),
};
would.push(("config", format!("write fairlead.toml with {text}")));
}
let Some(config) = seen.config() else {
return would;
};
if is_open(items, "hooks") {
let names: Vec<&str> = hooks_cmd::agents(&seen.repo)
.iter()
.filter(|a| a.stages.is_empty())
.map(|a| a.name)
.collect();
would.push((
"hooks",
format!("install the hooks for {}", names.join(", ")),
));
}
if is_open(items, "git-hook") {
let (_, written, _) = setup_items::commit_stage(&seen.repo);
let text = match (written, init_guard::on_path("lefthook")) {
(false, true) => "add the commit stage through lefthook, then run `lefthook install`",
(false, false) => "add the commit stage through lefthook; it stays inactive until lefthook is installed",
(true, true) => "run `lefthook install`",
(true, false) => "nothing: lefthook isn't on the PATH, so the commit stage stays inactive",
};
would.push(("git-hook", text.to_string()));
}
if is_open(items, "agents") {
let files: Vec<&str> = config
.agents
.files
.items()
.iter()
.map(String::as_str)
.collect();
would.push((
"agents",
format!("write the fairlead block in {}", files.join(", ")),
));
}
if writes && full {
let text = "run the smallest test file of each runner the new config names";
would.push(("proved", text.to_string()));
} else if full {
let chosen = setup_prove::plan(&seen.repo, &seen.tree, config).unwrap_or_default();
for c in chosen {
let text = format!("run {} on {}: {}", c.runner, c.file, c.inv.argv.join(" "));
would.push(("proved", text));
}
}
would
}
fn activate(repo: &Path) -> Result<(), String> {
let out = fairlead_core::process::command("lefthook", repo)
.arg("install")
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| format!("`lefthook install` didn't start: {e}"))?;
if out.status.success() {
return Ok(());
}
let said =
String::from_utf8_lossy(&out.stderr).into_owned() + &String::from_utf8_lossy(&out.stdout);
let last = said.lines().rev().find(|l| !l.trim().is_empty());
Err(format!(
"`lefthook install` failed: {}",
last.unwrap_or("no output").trim()
))
}
fn apply(cwd: &Path, mut seen: Seen, mut items: Vec<Item>, track: Track) -> Vec<Item> {
let mut tried: Vec<&'static str> = Vec::new();
let mut failed: BTreeMap<&'static str, String> = BTreeMap::new();
let mut attempt = |id: &'static str, result: Result<(), String>| {
tried.push(id);
if let Err(e) = result {
failed.insert(id, e);
}
};
if is_open(&items, "config") && seen.unwritten() {
let (text, _) = init_cmd::plain(&seen.root, &seen.tree.files);
attempt(
"config",
init_guard::land(&seen.root, &text).map_err(|lines| lines.join("; ")),
);
seen = Seen::read(cwd);
items = seen.items(track);
}
if let Some(config) = seen.config() {
if is_open(&items, "hooks") {
attempt(
"hooks",
hooks_cmd::install_agents(&seen.repo, config).map(drop),
);
}
if is_open(&items, "git-hook") {
let written = hooks_cmd::install_git_quietly(&seen.repo, config).map(drop);
let active = written.and_then(|()| match init_guard::on_path("lefthook") {
true => activate(&seen.repo),
false => Ok(()),
});
attempt("git-hook", active);
}
if is_open(&items, "agents") {
attempt("agents", agents_cmd::sync(&seen.root, config).map(drop));
}
if config.profile.is_full() && !seen.unwritten() {
match setup_prove::prove(&seen.repo, &seen.tree, config, setup_prove::LIMIT) {
Ok(0) => {}
Ok(_) => attempt("proved", Ok(())),
Err(e) => attempt("proved", Err(e)),
}
}
}
let mut after = setup_items::check(&seen.root, seen.loaded(), track);
for item in &mut after {
match (item.state, failed.get(item.id)) {
(State::Ok, _) if tried.contains(&item.id) => item.state = State::Fixed,
(State::Open, Some(why)) => {
item.text = format!("{}; setup couldn't fix it: {why}", item.text)
}
_ => {}
}
}
after
}