//! Implementation-only execution and bounded access to its evidence.
use crate::project::{self, Check};
use anyhow::{Context, Result};
use serde_json::{Value, json};
use std::{
collections::BTreeMap,
fs,
io::{Read, Seek, SeekFrom},
path::Path,
sync::atomic::AtomicBool,
};
pub fn schemas() -> Vec<Value> {
vec![
json!({"type":"function","function":{"name":"run_command","description":"Run an executable with argv in the private task workspace. Examples: [\"cargo\",\"test\",\"test_name\"], [\"cargo\",\"fmt\"]. No implicit shell; pipes/redirection are literal arguments. Commands finish or time out; background children are terminated. Returns exit_code, output tail and log_id. Honor task scope. Do not commit, reset Git, modify Chuggin state or operate outside this workspace. Configured final checks still run independently.","parameters":{"type":"object","properties":{"argv":{"type":"array","items":{"type":"string"}},"timeout_seconds":{"type":"integer","minimum":1,"maximum":600}},"required":["argv"]}}}),
json!({"type":"function","function":{"name":"compiler_diagnostics","description":"Run cargo check --all-targets and group Rust errors/warnings with source locations, snippets and compiler suggestions. Use after compiler failure rather than guessing APIs. This does not run tests or replace run_checks. Full raw output is available through log_id.","parameters":{"type":"object","properties":{}}}}),
json!({"type":"function","function":{"name":"read_command_log","description":"Read an earlier command/diagnostics log from this cycle in bounded byte chunks. Use log_id and next_offset returned by tools; do not repeat the same offset.","parameters":{"type":"object","properties":{"log_id":{"type":"string"},"offset":{"type":"integer","minimum":0}},"required":["log_id"]}}}),
]
}
pub fn run(root: &Path, art: &Path, id: &str, args: &Value, stop: &AtomicBool) -> Result<Value> {
let argv: Vec<String> =
serde_json::from_value(args["argv"].clone()).context("argv must be an array of strings")?;
anyhow::ensure!(
!argv.is_empty() && !argv[0].is_empty() && argv.len() <= 128,
"Supply an executable and at most 127 arguments"
);
anyhow::ensure!(
argv.iter().all(|s| !s.contains('\0'))
&& argv.iter().map(String::len).sum::<usize>() <= 16000,
"Command arguments too large or contain NUL"
);
let timeout = match args.get("timeout_seconds") {
Some(v) => v
.as_u64()
.context("timeout_seconds must be a positive integer")?,
None => 120,
};
anyhow::ensure!(
(1..=600).contains(&timeout),
"timeout_seconds must be 1–600"
);
let log_id = format!("command-{id}.log");
let result = project::check(
root,
&Check {
argv,
timeout_seconds: timeout,
},
&art.join(&log_id),
stop,
)?;
let bytes = fs::metadata(art.join(&log_id))?.len();
Ok(
json!({"exit_code":result.exit_code,"passed":result.passed,"timed_out":result.timed_out,"output_tail":output_tail(&result.output,7000),"log_id":log_id,"log_bytes":bytes,"instruction":"Use read_command_log for full output. A successful command does not replace configured final checks."}),
)
}
fn output_tail(text: &str, limit: usize) -> &str {
let mut start = text.len().saturating_sub(limit);
while !text.is_char_boundary(start) {
start += 1;
}
&text[start..]
}
pub fn read_log(art: &Path, args: &Value) -> Result<Value> {
let id = args["log_id"].as_str().context("Missing log_id")?;
anyhow::ensure!(
(id.starts_with("command-") || id.starts_with("diagnostics-"))
&& id.ends_with(".log")
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.'),
"Use a command/diagnostics log_id from this cycle"
);
let path = project::safe_path(art, id)?;
let mut file = fs::File::open(path)?;
let total = file.metadata()?.len();
let offset = match args.get("offset") {
Some(v) => v.as_u64().context("offset must be nonnegative")?,
None => 0,
};
anyhow::ensure!(offset <= total, "offset exceeds log size");
file.seek(SeekFrom::Start(offset))?;
let mut bytes = Vec::new();
file.take(6000).read_to_end(&mut bytes)?;
let next = offset + bytes.len() as u64;
Ok(
json!({"log_id":id,"offset":offset,"total_bytes":total,"text":String::from_utf8_lossy(&bytes),"next_offset":if next<total{Some(next)}else{None}}),
)
}
pub fn diagnostics(root: &Path, art: &Path, id: &str, stop: &AtomicBool) -> Result<Value> {
anyhow::ensure!(
root.join("Cargo.toml").is_file(),
"No Cargo.toml in task workspace"
);
let log_id = format!("diagnostics-{id}.log");
let log = art.join(&log_id);
let result = project::check(
root,
&Check {
argv: vec![
"cargo".into(),
"check".into(),
"--all-targets".into(),
"--message-format=json".into(),
],
timeout_seconds: 120,
},
&log,
stop,
)?;
let mut bytes = Vec::new();
fs::File::open(&log)?
.take(8_000_000)
.read_to_end(&mut bytes)?;
let mut summary = summarize(root, &String::from_utf8_lossy(&bytes));
summary["exit_code"] = json!(result.exit_code);
summary["passed"] = json!(result.passed);
summary["timed_out"] = json!(result.timed_out);
summary["log_id"] = json!(log_id);
summary["log_scan_truncated"] = json!(fs::metadata(log)?.len() > bytes.len() as u64);
if summary["diagnostics"]
.as_array()
.is_none_or(|d| d.is_empty())
{
summary["output_tail"] = json!(output_tail(&result.output, 3000));
}
Ok(summary)
}
fn summarize(root: &Path, raw: &str) -> Value {
let mut grouped: BTreeMap<String, Value> = BTreeMap::new();
let mut total = 0;
for line in raw.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if v["reason"] != "compiler-message" {
continue;
}
let m = &v["message"];
if !matches!(m["level"].as_str(), Some("error" | "warning")) {
continue;
}
total += 1;
let key = format!("{}:{}:{}", m["level"], m["code"]["code"], m["message"]);
let entry=grouped.entry(key).or_insert_with(||json!({"level":m["level"],"code":m["code"]["code"],"message":project::excerpt(m["message"].as_str().unwrap_or(""),800),"occurrences":0,"locations":[],"suggestions":[]}));
entry["occurrences"] = json!(entry["occurrences"].as_u64().unwrap_or(0) + 1);
for span in m["spans"]
.as_array()
.into_iter()
.flatten()
.filter(|s| s["is_primary"] == true)
.take(3)
{
let mut loc = json!({"file":span["file_name"],"line":span["line_start"],"column":span["column_start"],"label":span["label"]});
if let Some(path) = span["file_name"].as_str() {
let relative = Path::new(path)
.strip_prefix(root)
.ok()
.and_then(|p| p.to_str())
.unwrap_or(path);
if let Ok(source) = project::read_lines(
root,
relative,
span["line_start"]
.as_u64()
.unwrap_or(1)
.saturating_sub(1)
.max(1) as usize,
3,
) {
loc["source"] = json!(project::excerpt(&source, 600));
}
}
let locations = entry["locations"].as_array_mut().unwrap();
if locations.len() < 4 && !locations.contains(&loc) {
locations.push(loc);
}
}
for child in m["children"].as_array().into_iter().flatten().take(4) {
let suggestion = json!({"message":project::excerpt(child["message"].as_str().unwrap_or(""),500),"edits":child["spans"].as_array().into_iter().flatten().filter(|s|s["suggested_replacement"].is_string()).take(3).map(|s|json!({"file":s["file_name"],"line":s["line_start"],"column":s["column_start"],"line_end":s["line_end"],"column_end":s["column_end"],"replacement":s["suggested_replacement"],"applicability":s["suggestion_applicability"]})).collect::<Vec<_>>()});
let suggestions = entry["suggestions"].as_array_mut().unwrap();
if suggestions.len() < 4 && !suggestions.contains(&suggestion) {
suggestions.push(suggestion);
}
}
}
let unique = grouped.len();
let mut rows: Vec<_> = grouped.into_values().collect();
rows.sort_by_key(|d| d["level"] != "error");
let mut budget = 0;
rows.retain(|v| {
budget += v.to_string().len();
budget <= 8500
});
json!({"reported_messages":total,"unique_diagnostics":unique,"truncated":rows.len()<unique,"diagnostics":rows})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn execution_preserves_arguments_exit_status_and_log_continuation() {
let root = tempfile::tempdir().unwrap();
let art = tempfile::tempdir().unwrap();
let stop = AtomicBool::new(false);
let result = run(
root.path(),
art.path(),
"0-0",
&json!({"argv":["printf","%s","literal $(touch unexpected) ; | >"]}),
&stop,
)
.unwrap();
assert_eq!(result["exit_code"], 0);
assert_eq!(result["output_tail"], "literal $(touch unexpected) ; | >");
assert!(!root.path().join("unexpected").exists());
let fail = run(
root.path(),
art.path(),
"0-1",
&json!({"argv":["sh","-c","echo failure >&2; exit 7"]}),
&stop,
)
.unwrap();
assert_eq!(fail["exit_code"], 7);
assert_eq!(fail["passed"], false);
fs::write(art.path().join("command-large.log"), "a".repeat(15000)).unwrap();
let first = read_log(art.path(), &json!({"log_id":"command-large.log"})).unwrap();
assert_eq!(first["next_offset"], 6000);
let next = read_log(
art.path(),
&json!({"log_id":"command-large.log","offset":12000}),
)
.unwrap();
assert!(next["next_offset"].is_null());
assert_eq!(next["text"].as_str().unwrap().len(), 3000);
assert!(read_log(art.path(), &json!({"log_id":"command-../secret.log"})).is_err());
assert!(
run(
root.path(),
art.path(),
"0-2",
&json!({"argv":["true"],"timeout_seconds":0}),
&stop
)
.is_err()
);
}
#[test]
fn timeout_stops_descendants() {
let root = tempfile::tempdir().unwrap();
let art = tempfile::tempdir().unwrap();
let result = run(
root.path(),
art.path(),
"0-0",
&json!({"argv":["sh","-c","(sleep 2; touch escaped) & wait"],"timeout_seconds":1}),
&AtomicBool::new(false),
)
.unwrap();
assert_eq!(result["timed_out"], true);
std::thread::sleep(std::time::Duration::from_millis(1300));
assert!(!root.path().join("escaped").exists());
}
#[test]
fn real_compiler_errors_include_locations_and_logs() {
let root = tempfile::tempdir().unwrap();
let art = tempfile::tempdir().unwrap();
project::write(
root.path(),
"Cargo.toml",
"[package]\nname=\"diagnostic_fixture\"\nversion=\"0.1.0\"\nedition=\"2024\"\n",
)
.unwrap();
project::write(
root.path(),
"src/lib.rs",
"pub fn broken() -> u32 { \"wrong\" }\n",
)
.unwrap();
let result = diagnostics(root.path(), art.path(), "0-0", &AtomicBool::new(false)).unwrap();
assert_eq!(result["passed"], false);
let errors = result["diagnostics"].as_array().unwrap();
assert!(
errors.iter().any(|d| d["code"] == "E0308"
&& d["locations"][0]["line"] == 1
&& d["locations"][0]["source"]
.as_str()
.unwrap()
.contains("broken")),
"{result}"
);
assert!(
read_log(art.path(), &json!({"log_id":result["log_id"]})).unwrap()["text"]
.as_str()
.unwrap()
.contains("compiler-message")
);
}
#[test]
fn duplicate_diagnostics_keep_suggestions_and_count() {
let root = tempfile::tempdir().unwrap();
let msg = json!({"reason":"compiler-message","message":{"level":"error","code":{"code":"E0308"},"message":"mismatched types","spans":[],"children":[{"message":"convert this","spans":[{"file_name":"src/lib.rs","line_start":1,"column_start":4,"suggested_replacement":".into()","suggestion_applicability":"MachineApplicable"}]}]}});
let result = summarize(root.path(), &format!("{msg}\n{msg}\n"));
assert_eq!(result["unique_diagnostics"], 1);
assert_eq!(result["diagnostics"][0]["occurrences"], 2);
assert_eq!(
result["diagnostics"][0]["suggestions"][0]["edits"][0]["replacement"],
".into()"
);
}
}