1use std::path::{Path, PathBuf};
24use std::process::Command;
25use std::time::Duration;
26
27use serde_json::{json, Value};
28
29use crate::gitscan;
30
31type R<T> = Result<T, Box<dyn std::error::Error>>;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Check {
36 pub checkers: Option<String>,
38}
39
40pub fn resolve(flag: Option<&str>, checkers: Option<&str>, repo: &Path) -> R<Option<Check>> {
42 let (configured, cfg_checkers) = config_default(repo);
43 let want = match flag {
44 Some("sem") => true,
45 Some("none") => false,
46 Some(other) => return Err(format!("--check wants `sem` or `none`, not `{other}`").into()),
47 None => match configured.as_deref() {
48 Some("sem") => true,
49 None | Some("none") | Some("") => false,
50 Some(other) => {
51 return Err(format!(
52 ".weave/config: land.check wants `sem` or `none`, not `{other}`"
53 )
54 .into())
55 }
56 },
57 };
58 Ok(want.then(|| Check {
59 checkers: checkers.map(str::to_string).or(cfg_checkers),
60 }))
61}
62
63pub fn config_default(repo: &Path) -> (Option<String>, Option<String>) {
65 let top = gitscan::git(repo, &["rev-parse", "--show-toplevel"])
66 .map(|s| PathBuf::from(s.trim()))
67 .unwrap_or_else(|_| repo.to_path_buf());
68 let file = top.join(".weave/config");
69 if !file.exists() {
70 return (None, None);
71 }
72 let get = |key: &str| {
73 Command::new("git")
74 .arg("config")
75 .arg("--file")
76 .arg(&file)
77 .arg("--get")
78 .arg(key)
79 .output()
80 .ok()
81 .filter(|o| o.status.success())
82 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
83 .filter(|s| !s.is_empty())
84 };
85 (get("land.check"), get("land.checkers"))
86}
87
88fn sem_binary() -> Option<PathBuf> {
90 if let Some(p) = std::env::var_os("WEAVE_SEM").filter(|p| !p.is_empty()) {
91 let p = PathBuf::from(p);
92 return p.is_file().then_some(p);
93 }
94 let path = std::env::var_os("PATH")?;
95 std::env::split_paths(&path)
96 .map(|d| d.join("sem"))
97 .find(|p| p.is_file())
98}
99
100#[derive(Debug, Clone)]
102pub enum Outcome {
103 Pass(Value),
105 Refused {
108 reason: &'static str,
109 report: String,
110 doc: Option<Value>,
111 },
112}
113
114pub fn run(
118 dir: &Path,
119 tip: &str,
120 tree: &str,
121 check: &Check,
122 limit: Duration,
123 env: &[(&str, &str)],
124) -> R<Outcome> {
125 let Some(sem) = sem_binary() else {
126 return Ok(Outcome::Refused {
127 reason: "sem not installed",
128 report:
129 "land: --check sem was asked for (or .weave/config sets land.check = sem), but \
130 `sem` is not installed: it is not on PATH and WEAVE_SEM does not name it. \
131 The merged tree was not verified, so nothing was published. Install sem, \
132 or pass --check none to land without it."
133 .to_string(),
134 doc: None,
135 });
136 };
137 let mut cmd = Command::new(&sem);
138 cmd.args(["check", "--base", tip, "--json"])
139 .current_dir(dir);
140 if let Some(c) = &check.checkers {
141 cmd.args(["--checkers", c]);
142 }
143 cmd.arg("--timeout").arg(limit.as_secs().max(1).to_string());
144 for (k, v) in env {
145 cmd.env(k, v);
146 }
147 let run = gitscan::run_bounded(cmd, "sem check", None, limit + Duration::from_secs(60));
149 let (status, stdout, stderr) = match run {
150 Ok(r) => r,
151 Err(e) => {
152 return Ok(Outcome::Refused {
153 reason: "sem check could not decide",
154 report: format!("land: sem check did not finish: {e}"),
155 doc: None,
156 })
157 }
158 };
159 let doc: Option<Value> = serde_json::from_slice(&stdout).ok();
160 let code = status.code();
161 let Some(doc) = doc else {
162 let tail = |b: &[u8]| {
163 let t = String::from_utf8_lossy(b);
164 let lines: Vec<&str> = t.lines().collect();
165 lines[lines.len().saturating_sub(30)..].join("\n")
166 };
167 return Ok(Outcome::Refused {
168 reason: "sem check could not decide",
169 report: format!(
170 "land: sem check exited {} without a readable result:\n{}\n{}",
171 code.map_or("on a signal".into(), |c| c.to_string()),
172 tail(&stdout),
173 tail(&stderr)
174 ),
175 doc: None,
176 });
177 };
178 match code {
179 Some(0) if doc["verdict"] == "pass" && doc["head"]["tree"].as_str() != Some(tree) => {
180 let untracked = gitscan::git(dir, &["ls-files", "--others", "--exclude-standard"])
181 .unwrap_or_default();
182 Ok(Outcome::Refused {
183 reason: "sem check checked another tree",
184 report: format!(
185 "land: sem check passed, but on a tree that is not the one to be published \
186 (checked {}, publishing {tree}): untracked files in the working tree are \
187 part of what the tools saw:\n{}\nRemove them, ignore them (.gitignore), or \
188 commit them, then run land again.",
189 doc["head"]["tree"].as_str().unwrap_or("?"),
190 untracked
191 .lines()
192 .map(|l| format!(" {l}"))
193 .collect::<Vec<_>>()
194 .join("\n")
195 ),
196 doc: Some(doc),
197 })
198 }
199 Some(0) if doc["verdict"] == "pass" => Ok(Outcome::Pass(doc)),
200 Some(1) => Ok(Outcome::Refused {
201 reason: "sem check failed",
202 report: render(&doc, "FAILED"),
203 doc: Some(doc),
204 }),
205 _ => Ok(Outcome::Refused {
206 reason: "sem check could not decide",
207 report: render(&doc, "COULD NOT DECIDE"),
208 doc: Some(doc),
209 }),
210 }
211}
212
213pub fn render(doc: &Value, headline: &str) -> String {
216 let s = |v: &Value| v.as_str().unwrap_or("").to_string();
217 let mut out = vec![format!("land: sem check {headline} on the merged tree:")];
218 if let Some(e) = doc.get("error").and_then(Value::as_str) {
219 out.push(format!(" {e}"));
220 }
221 for n in doc["notes"].as_array().into_iter().flatten() {
222 out.push(format!(" note: {}", s(n)));
223 }
224 for c in doc["checkers"].as_array().into_iter().flatten() {
225 out.push(format!(
226 " {} {} ({}, {}, {} rechecked)",
227 s(&c["name"]),
228 s(&c["verdict"]).to_uppercase(),
229 s(&c["tool"]),
230 s(&c["mode"]),
231 c["filesRecheckedCount"]
232 ));
233 for r in c["reasons"].as_array().into_iter().flatten() {
234 out.push(format!(" because: {}", s(r)));
235 }
236 let diags = c["diagnostics"].as_array().cloned().unwrap_or_default();
237 for d in diags.iter().take(100) {
238 for (i, line) in s(d).lines().enumerate() {
239 out.push(format!("{}{line}", if i == 0 { " " } else { " " }));
240 }
241 }
242 if diags.len() > 100 {
243 out.push(format!(" ... {} more", diags.len() - 100));
244 }
245 }
246 out.join("\n")
247}
248
249pub fn summary(doc: &Value) -> String {
251 let parts: Vec<String> = doc["checkers"]
252 .as_array()
253 .into_iter()
254 .flatten()
255 .map(|c| {
256 format!(
257 "{} {} {} ({} rechecked)",
258 c["name"].as_str().unwrap_or("?"),
259 c["verdict"].as_str().unwrap_or("?"),
260 c["mode"].as_str().unwrap_or("?"),
261 c["filesRecheckedCount"]
262 )
263 })
264 .collect();
265 parts.join("; ")
266}
267
268pub fn certificate(tip: &str, head: &str, tree: &str, outcome: &Outcome) -> Value {
271 let (verdict, reason, doc) = match outcome {
272 Outcome::Pass(d) => ("pass", None, Some(d)),
273 Outcome::Refused { reason, doc, .. } => ("refused", Some(*reason), doc.as_ref()),
274 };
275 json!({
276 "kind": "sem-check",
277 "onto": tip,
278 "head": head,
279 "tree": tree,
280 "verdict": verdict,
281 "reason": reason,
282 "sem": doc.map(|d| json!({
283 "verdict": d["verdict"],
284 "exitCode": d["exitCode"],
285 "checkers": d["checkers"].as_array().map(|a| a.iter().map(|c| json!({
286 "name": c["name"], "tool": c["tool"], "toolVersion": c["toolVersion"],
287 "verdict": c["verdict"], "mode": c["mode"], "reasons": c["reasons"],
288 "filesRechecked": c["filesRechecked"], "errors": c["errors"],
289 "diagnostics": c["diagnostics"],
290 })).collect::<Vec<_>>()),
291 "certificate": d["certificate"],
292 })),
293 })
294}