use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use rigswig_core::audit::{self, Audit, Fleet, Progress};
use crate::{Remote, expand, home, load_remote, private_folders, real_path, this_os, with_harness};
pub fn repo(
path: &Path,
tool: Option<&str>,
session: bool,
usage: Option<Option<&str>>,
progress: &mut dyn FnMut(Progress),
) -> Result<(Audit, u8), String> {
let ((out, score), _) = with_harness(path, tool, session, |a, ctx, score| {
progress(Progress { stage: "Reading the harness".into(), fraction: 0.0 });
let (signals, note) = audit::user_signals(ctx);
let h = a.resolve(ctx);
let mut notes: Vec<String> = note.into_iter().collect();
let usage = usage.and_then(|since| {
progress(Progress { stage: "Reading session logs (metadata only)".into(), fraction: 0.05 });
a.usage(ctx, since).map_err(|e| notes.push(format!("usage: {e}"))).ok()
});
let mut out = audit::audit_with(ctx, &h, &audit::Options { signals, usage }, &mut |p| {
progress(Progress { stage: p.stage, fraction: 0.1 + 0.9 * p.fraction })
});
out.notes.extend(notes);
(out, score)
})?;
Ok((out, score))
}
pub fn remote(r: &Remote, tool: Option<&str>, progress: &mut dyn FnMut(Progress)) -> Result<Audit, String> {
let what = match r {
Remote::Github(spec) => format!("Downloading {} from codeload.github.com", spec.label()),
Remote::Archive(p) => {
format!("Reading {}", p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())
}
};
progress(Progress { stage: what.clone(), fraction: 0.0 });
let (fs, root, label, summary) = load_remote(r, &mut |bytes| {
let mb = bytes as f64 / (1024.0 * 1024.0);
progress(Progress { stage: format!("{what} ({mb:.1} MB)"), fraction: 0.4 * (1.0 - (-mb / 8.0).exp()) })
})?;
progress(Progress { stage: "Unpacked in memory; reading the harness".into(), fraction: 0.45 });
let adapters = rigswig_core::adapters::all();
let ctx = rigswig_core::adapter::Ctx {
fs: &fs,
env: std::collections::BTreeMap::new(),
home: PathBuf::from(crate::github::HOME),
repo: root,
os: this_os(),
};
let a = match tool {
Some(t) => {
adapters.iter().find(|a| a.id() == t).ok_or(format!("no harness called {t} (see `rigswig tools`)"))?
}
None => adapters.iter().rev().max_by_key(|a| a.detect(&ctx).score).expect("an adapter"),
};
let others: Vec<&str> =
adapters.iter().filter(|o| o.id() != a.id() && o.detect(&ctx).score > 50).map(|o| o.id()).collect();
let h = a.resolve(&ctx);
let mut out = audit::audit_with(&ctx, &h, &audit::Options::default(), &mut |p| {
progress(Progress { stage: p.stage, fraction: 0.5 + 0.5 * p.fraction })
});
out.repo = rigswig_core::read::redact::printable(&label);
out.notes.insert(0, summary);
if !others.is_empty() {
out.notes
.insert(1, format!("also configured in this repo: {} (add --tool <id> to audit it)", others.join(", ")));
}
Ok(out)
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FleetRun {
pub fleet: Fleet,
pub audits: Vec<Audit>,
pub skipped: Vec<String>,
pub private_skipped: usize,
pub errors: Vec<String>,
}
pub fn fleet(patterns: &[String], tool: Option<&str>, progress: &mut dyn FnMut(Progress)) -> Result<FleetRun, String> {
let mut paths: Vec<PathBuf> = Vec::new();
for p in patterns {
paths.extend(expand(p)?);
}
paths.sort();
paths.dedup();
let private = home().map(|h| private_folders(&h)).unwrap_or_default();
let before = paths.len();
paths.retain(|p| {
let r = real_path(p).unwrap_or_else(|_| p.clone());
!private.iter().any(|x| r.starts_with(x))
});
let private_skipped = before - paths.len();
let n = paths.len().max(1);
let (mut audits, mut skipped, mut errors) = (Vec::new(), Vec::new(), Vec::new());
for (i, p) in paths.iter().enumerate() {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let shown = rigswig_core::read::redact::printable(&name);
let mut each = |x: Progress| {
progress(Progress {
stage: format!("{shown} ({} of {}): {}", i + 1, paths.len(), x.stage),
fraction: (i as f64 + x.fraction) / n as f64,
})
};
match repo(p, tool, false, None, &mut each) {
Ok((a, score)) if score > 50 || p.join(".git").exists() => audits.push(a),
Ok(_) => skipped.push(shown),
Err(e) => errors.push(format!("{shown}: {e}")),
}
}
if audits.is_empty() {
return Err("none of those folders shows a harness rigswig knows".into());
}
let fleet = audit::fleet(&audits);
Ok(FleetRun { fleet, audits, skipped, private_skipped, errors })
}
#[derive(serde::Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Job {
pub id: u64,
pub state: &'static str,
pub mode: &'static str,
pub stage: String,
pub fraction: f64,
pub elapsed_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip)]
started: Option<Instant>,
}
#[derive(Clone, Default)]
pub struct Jobs {
current: Arc<Mutex<Option<Job>>>,
next: Arc<Mutex<u64>>,
allow_github: bool,
}
enum Work {
Repo,
Fleet(Vec<String>),
Github(crate::github::Spec),
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct Start {
mode: String,
#[serde(default)]
repos: Vec<String>,
#[serde(default)]
repo: Option<String>,
}
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Poll {
id: u64,
}
const MAX_PATTERNS: usize = 32;
impl Jobs {
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Job>> {
self.current.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn route(&self, route: &str, repo: &Path, tool: &str, body: &str) -> Result<String, (u16, String)> {
let bad = |e: serde_json::Error| (400, format!("bad request: {e}"));
match route {
"/api/audit/start" => {
let req: Start = serde_json::from_str(body).map_err(bad)?;
let (mode, work): (&'static str, Work) = match req.mode.as_str() {
"repo" if req.repos.is_empty() && req.repo.is_none() => ("repo", Work::Repo),
"repo" => return Err((400, "a repo audit takes no repos".into())),
"fleet" => {
let ok = req.repo.is_none()
&& !req.repos.is_empty()
&& req.repos.len() <= MAX_PATTERNS
&& req.repos.iter().all(|r| !r.trim().is_empty() && r.len() <= 1024);
if !ok {
return Err((400, format!("a fleet audit needs 1 to {MAX_PATTERNS} folders or patterns")));
}
("fleet", Work::Fleet(req.repos))
}
"github" => {
if !self.allow_github {
return Err((
403,
"this page does not download: start it with rigswig ui --allow-github to audit GitHub repos"
.into(),
));
}
let input = req
.repo
.filter(|_| req.repos.is_empty())
.ok_or((400, "a GitHub audit needs one repo".to_string()))?;
let spec = crate::github::Spec::parse(&input).map_err(|e| (400, e))?;
("github", Work::Github(spec))
}
_ => return Err((400, "mode is repo, fleet or github".into())),
};
Ok(serde_json::to_string(&self.start(mode, work, repo.to_path_buf(), tool.to_string())?)
.expect("serialises"))
}
"/api/audit/poll" => {
let req: Poll = serde_json::from_str(body).map_err(bad)?;
let cur = self.lock();
match cur.as_ref() {
Some(j) if j.id == req.id => {
let mut j = j.clone();
j.elapsed_ms = j.started.map_or(0, |s| s.elapsed().as_millis() as u64);
if j.state == "running" {
j.result = None;
}
Ok(serde_json::to_string(&j).expect("serialises"))
}
_ => Err((404, "no such audit".into())),
}
}
_ => Err((404, "no such action".into())),
}
}
pub fn new(allow_github: bool) -> Self {
Self { allow_github, ..Self::default() }
}
fn start(&self, mode: &'static str, work: Work, repo: PathBuf, tool: String) -> Result<Job, (u16, String)> {
let id = {
let mut n = self.next.lock().unwrap_or_else(|e| e.into_inner());
*n += 1;
*n
};
let job = Job {
id,
state: "running",
mode,
stage: "Starting".into(),
fraction: 0.0,
elapsed_ms: 0,
result: None,
error: None,
started: Some(Instant::now()),
};
{
let mut cur = self.lock();
if cur.as_ref().is_some_and(|j| j.state == "running") {
return Err((409, "an audit is already running".into()));
}
*cur = Some(job.clone());
}
let me = self.clone();
std::thread::spawn(move || {
let update = |f: &mut dyn FnMut(&mut Job)| {
if let Some(j) = me.lock().as_mut().filter(|j| j.id == id) {
f(j);
}
};
let mut progress = |p: Progress| {
update(&mut |j| {
j.stage = p.stage.clone();
j.fraction = j.fraction.max(p.fraction.clamp(0.0, 1.0));
})
};
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match &work {
Work::Repo => repo_json(&repo, &tool, &mut progress),
Work::Fleet(repos) => {
fleet(repos, Some(&tool), &mut progress).map(|f| serde_json::to_value(&f).expect("serialises"))
}
Work::Github(spec) => remote(&Remote::Github(spec.clone()), None, &mut progress)
.map(|a| serde_json::to_value(&a).expect("serialises")),
}));
let outcome = run.unwrap_or_else(|_| Err("the audit stopped on an internal error".into()));
update(&mut |j| match &outcome {
Ok(v) => {
j.state = "done";
j.stage = "Done".into();
j.fraction = 1.0;
j.result = Some(v.clone());
}
Err(e) => {
j.state = "failed";
j.error = Some(rigswig_core::read::redact::printable(e));
}
});
});
Ok(job)
}
}
fn repo_json(repo: &Path, tool: &str, progress: &mut dyn FnMut(Progress)) -> Result<serde_json::Value, String> {
repo_audit(repo, tool, progress).map(|a| serde_json::to_value(&a).expect("serialises"))
}
fn repo_audit(repo: &Path, tool: &str, progress: &mut dyn FnMut(Progress)) -> Result<Audit, String> {
self::repo(repo, Some(tool), true, None, progress).map(|(a, _)| a)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jobs_check_their_input_before_starting_anything() {
let j = Jobs::default();
let repo = Path::new("/nowhere");
let code = |r: Result<String, (u16, String)>| r.err().map(|e| e.0);
assert_eq!(code(j.route("/api/audit/start", repo, "claude-code", "{}")), Some(400), "mode is required");
assert_eq!(code(j.route("/api/audit/start", repo, "claude-code", r#"{"mode":"all"}"#)), Some(400));
assert_eq!(code(j.route("/api/audit/start", repo, "claude-code", r#"{"mode":"fleet"}"#)), Some(400));
assert_eq!(
code(j.route("/api/audit/start", repo, "claude-code", r#"{"mode":"fleet","repos":[" "]}"#)),
Some(400)
);
let many = serde_json::json!({ "mode": "fleet", "repos": vec!["~/x"; MAX_PATTERNS + 1] }).to_string();
assert_eq!(code(j.route("/api/audit/start", repo, "claude-code", &many)), Some(400));
assert_eq!(
code(j.route("/api/audit/start", repo, "claude-code", r#"{"mode":"repo","repos":["~/x"]}"#)),
Some(400)
);
assert_eq!(code(j.route("/api/audit/start", repo, "claude-code", r#"{"mode":"repo","extra":1}"#)), Some(400));
assert_eq!(code(j.route("/api/audit/poll", repo, "claude-code", r#"{"id":7}"#)), Some(404), "no such job");
assert_eq!(code(j.route("/api/audit/other", repo, "claude-code", "{}")), Some(404));
assert_eq!(
code(j.route("/api/audit/start", repo, "claude-code", r#"{"mode":"github","repo":"o/r"}"#)),
Some(403),
"a page started without --allow-github never downloads"
);
let gh = Jobs::new(true);
for bad in [
r#"{"mode":"github"}"#,
r#"{"mode":"github","repo":"https://evil.example/o/r"}"#,
r#"{"mode":"github","repo":"o/r@../x"}"#,
r#"{"mode":"github","repo":"o/r","repos":["~/x"]}"#,
r#"{"mode":"repo","repo":"o/r"}"#,
] {
assert_eq!(code(gh.route("/api/audit/start", repo, "claude-code", bad)), Some(400), "{bad}");
}
assert!(j.lock().is_none() && gh.lock().is_none(), "nothing was started");
}
#[test]
fn a_failed_job_reports_its_error_when_polled() {
let j = Jobs::default();
let started =
j.route("/api/audit/start", Path::new("/no/such/repo/anywhere"), "claude-code", r#"{"mode":"repo"}"#);
let id = serde_json::from_str::<serde_json::Value>(&started.unwrap()).unwrap()["id"].as_u64().unwrap();
let mut last = serde_json::Value::Null;
for _ in 0..200 {
last = serde_json::from_str(
&j.route("/api/audit/poll", Path::new("/"), "claude-code", &format!(r#"{{"id":{id}}}"#)).unwrap(),
)
.unwrap();
if last["state"] != "running" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(last["state"], "failed", "{last}");
assert!(last["error"].as_str().unwrap().contains("no such folder"), "{last}");
assert!(last.get("result").is_none());
}
}