Skip to main content

harness_sensors_rust/
lib.rs

1//! Rust-specific sensors.
2//!
3//! Each sensor runs the corresponding `cargo` subcommand and converts its
4//! diagnostic JSON stream into [`Signal`]s. The `agent_hint` field is filled
5//! with imperative correction language so the model can act on it directly.
6
7use async_trait::async_trait;
8use harness_core::{
9    Action, CodeSpan, Execution, Sensor, SensorError, SensorId, Severity, Signal, Stage, World,
10};
11use serde::Deserialize;
12
13/// `cargo check` — fast type / borrow-check sensor. Self-correct stage.
14pub struct CargoCheck {
15    id: SensorId,
16}
17
18impl CargoCheck {
19    pub fn new() -> Self {
20        Self {
21            id: "cargo-check".into(),
22        }
23    }
24}
25
26impl Default for CargoCheck {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32#[async_trait]
33impl Sensor for CargoCheck {
34    fn id(&self) -> &SensorId {
35        &self.id
36    }
37    fn kind(&self) -> Execution {
38        Execution::Computational
39    }
40    fn stage(&self) -> Stage {
41        Stage::SelfCorrect
42    }
43
44    async fn observe(&self, _action: &Action, world: &World) -> Result<Vec<Signal>, SensorError> {
45        let out = world
46            .runner
47            .exec(
48                "cargo",
49                &["check", "--message-format=json", "--quiet"],
50                Some(world.repo.root.as_path()),
51            )
52            .await
53            .map_err(|e| SensorError::Failed {
54                id: self.id.clone(),
55                reason: e.to_string(),
56            })?;
57        Ok(parse_cargo_messages(&out.stdout, &self.id))
58    }
59}
60
61/// `cargo clippy --message-format=json -- -D warnings` — lint sensor.
62pub struct Clippy {
63    id: SensorId,
64}
65
66impl Clippy {
67    pub fn new() -> Self {
68        Self {
69            id: "clippy".into(),
70        }
71    }
72}
73
74impl Default for Clippy {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80#[async_trait]
81impl Sensor for Clippy {
82    fn id(&self) -> &SensorId {
83        &self.id
84    }
85    fn kind(&self) -> Execution {
86        Execution::Computational
87    }
88    fn stage(&self) -> Stage {
89        Stage::PreCommit
90    }
91
92    async fn observe(&self, _action: &Action, world: &World) -> Result<Vec<Signal>, SensorError> {
93        let out = world
94            .runner
95            .exec(
96                "cargo",
97                &[
98                    "clippy",
99                    "--message-format=json",
100                    "--quiet",
101                    "--",
102                    "-D",
103                    "warnings",
104                ],
105                Some(world.repo.root.as_path()),
106            )
107            .await
108            .map_err(|e| SensorError::Failed {
109                id: self.id.clone(),
110                reason: e.to_string(),
111            })?;
112        Ok(parse_cargo_messages(&out.stdout, &self.id))
113    }
114}
115
116// ---------- cargo JSON diagnostic parsing ----------
117
118#[derive(Debug, Deserialize)]
119struct CargoMsg {
120    reason: String,
121    #[serde(default)]
122    message: Option<RustcDiag>,
123}
124
125#[derive(Debug, Deserialize)]
126struct RustcDiag {
127    message: String,
128    level: String,
129    #[serde(default)]
130    spans: Vec<Span>,
131    #[serde(default)]
132    code: Option<DiagCode>,
133}
134
135#[derive(Debug, Deserialize)]
136struct Span {
137    file_name: String,
138    line_start: u32,
139    column_start: u32,
140    #[serde(default)]
141    is_primary: bool,
142}
143
144#[derive(Debug, Deserialize)]
145struct DiagCode {
146    code: String,
147}
148
149fn parse_cargo_messages(stdout: &str, origin: &str) -> Vec<Signal> {
150    let mut out = Vec::new();
151    for line in stdout.lines() {
152        let line = line.trim();
153        if line.is_empty() || !line.starts_with('{') {
154            continue;
155        }
156        let msg: CargoMsg = match serde_json::from_str(line) {
157            Ok(m) => m,
158            Err(_) => continue,
159        };
160        if msg.reason != "compiler-message" {
161            continue;
162        }
163        let diag = match msg.message {
164            Some(d) => d,
165            None => continue,
166        };
167        let severity = match diag.level.as_str() {
168            "error" | "error: internal compiler error" => Severity::Block,
169            "warning" => Severity::Warn,
170            _ => Severity::Hint,
171        };
172        let primary = diag
173            .spans
174            .iter()
175            .find(|s| s.is_primary)
176            .or_else(|| diag.spans.first());
177        let location = primary.map(|s| CodeSpan {
178            path: s.file_name.clone().into(),
179            line: s.line_start,
180            column: s.column_start,
181            length: 0,
182        });
183        let code_str = diag
184            .code
185            .as_ref()
186            .map(|c| format!(" [{}]", c.code))
187            .unwrap_or_default();
188        let agent_hint = build_hint(&diag.message, &diag.level);
189        out.push(Signal {
190            severity,
191            origin: origin.to_string(),
192            message: format!("{}{code_str}", diag.message),
193            agent_hint: Some(agent_hint),
194            auto_fix: None,
195            location,
196        });
197    }
198    out
199}
200
201fn build_hint(message: &str, level: &str) -> String {
202    match level {
203        "error" => {
204            format!("Fix this compilation error: {message}. Edit the file and re-run cargo check.")
205        }
206        "warning" => format!("Address this warning: {message}. Prefer fixing over silencing."),
207        _ => message.to_string(),
208    }
209}