use std::path::{Path, PathBuf};
use std::process::ExitCode;
use fairlead_core::config::{Config, LoadOptions};
use crate::knowledge::sync::{self, Plan, State};
#[derive(clap::Subcommand)]
pub enum SkillsAction {
Sync {
#[arg(long)]
check: bool,
#[arg(long, conflicts_with = "check")]
clean: bool,
},
Eval {
#[arg(long, value_name = "DATE", conflicts_with = "limit")]
since: Option<String>,
#[arg(long, value_name = "N")]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
Report(crate::skills_report_cmd::ReportArgs),
}
fn config_at(cwd: &Path) -> Result<(PathBuf, Config), String> {
let loaded = fairlead_core::config::load(cwd, &LoadOptions::from_process(Vec::new()))
.map_err(|e| e.to_string())?;
let root = if loaded.files.is_empty() {
crate::graph_cmd::repo_root(cwd)
} else {
loaded.root.clone()
};
Ok((root, loaded.config))
}
pub fn run(action: SkillsAction, cwd: &Path) -> ExitCode {
if let SkillsAction::Report(args) = action {
return crate::skills_report_cmd::run(
crate::skills_report_cmd::SkillsAction::Report(args),
cwd,
);
}
if let SkillsAction::Eval { since, limit, json } = action {
return crate::skills_eval_cmd::run(
crate::skills_eval_cmd::SkillsAction::Eval { since, limit, json },
cwd,
);
}
let (root, config) = match config_at(cwd) {
Ok(found) => found,
Err(e) => {
eprintln!("skills: {e}");
return ExitCode::from(2);
}
};
match action {
SkillsAction::Sync { clean: true, .. } => clean(&root),
SkillsAction::Sync { check: true, .. } => check(&root, &config),
SkillsAction::Sync { .. } => write(&root, &config),
SkillsAction::Eval { .. } | SkillsAction::Report(_) => unreachable!("handled above"),
}
}
pub fn plan(root: &Path, config: &Config) -> Plan {
let skills = &config.skills;
let wants_modules = skills.targets.items().iter().any(|t| t == "cursor")
&& skills.routes.items().iter().any(|r| !r.modules.is_empty());
let modules = if wants_modules {
let tree = fairlead_lang::tree::Tree::scan(root);
let packages = fairlead_lang::workspace::discover(&tree);
fairlead_tests::modules::Modules::discover(&tree, &packages, &config.modules)
.unwrap_or_default()
} else {
fairlead_tests::modules::Modules::default()
};
let roots = |name: &str| -> Vec<String> {
modules
.all()
.iter()
.filter(|m| m.name == name)
.map(|m| m.root.clone())
.collect()
};
sync::plan(root, skills, &roots)
}
fn remove(root: &Path, path: &str) -> Result<(), String> {
let full = root.join(path);
std::fs::remove_file(&full).map_err(|e| format!("can't remove {path}: {e}"))?;
if path.ends_with("/SKILL.md") {
if let Some(dir) = full.parent() {
let _ = std::fs::remove_dir(dir);
}
}
Ok(())
}
fn write(root: &Path, config: &Config) -> ExitCode {
let plan = plan(root, config);
if !plan.problems.is_empty() {
for p in &plan.problems {
eprintln!("{p}");
}
eprintln!("skills sync: nothing written; fix the routes above first");
return ExitCode::FAILURE;
}
let mut failed = false;
for (t, state) in &plan.files {
let full = root.join(&t.path);
let done = match state {
State::Fresh => Ok(format!("unchanged {}", t.path)),
State::Conflict => {
failed = true;
Ok(sync::conflict(&t.path))
}
State::Missing | State::Stale => full
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::write(&full, &t.text))
.map(|()| format!("wrote {} (from {})", t.path, t.source))
.map_err(|e| format!("can't write {}: {e}", t.path)),
};
match done {
Ok(line) => println!("{line}"),
Err(e) => {
failed = true;
eprintln!("{e}");
}
}
}
for o in &plan.orphans {
match remove(root, o) {
Ok(()) => println!("removed {o}"),
Err(e) => {
failed = true;
eprintln!("{e}");
}
}
}
for n in &plan.not_copied {
println!("not copied: {n} (sync copies SKILL.md only)");
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn check(root: &Path, config: &Config) -> ExitCode {
let plan = plan(root, config);
let drift = plan.drift();
if drift.is_empty() {
println!("skills in sync: {} file(s)", plan.files.len());
return ExitCode::SUCCESS;
}
for line in &drift {
println!("{line}");
}
println!("{} problem(s); run `fairlead skills sync`", drift.len());
ExitCode::FAILURE
}
fn clean(root: &Path) -> ExitCode {
let found = sync::generated(root);
if found.is_empty() {
println!("nothing to clean: no file carries the generated header");
return ExitCode::SUCCESS;
}
let mut failed = false;
for path in &found {
match remove(root, path) {
Ok(()) => println!("removed {path}"),
Err(e) => {
failed = true;
eprintln!("{e}");
}
}
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
pub fn doctor_line(root: &Path, config: &Config) -> Option<String> {
if config.skills.routes.items().is_empty() && sync::generated(root).is_empty() {
return None;
}
let drift = plan(root, config).drift();
if drift.is_empty() {
return None;
}
Some(format!(
"skills sync: {} problem(s), such as `{}`; run `fairlead skills sync --check`\n",
drift.len(),
drift[0]
))
}