1mod cargo;
2mod changes;
3mod dry_run;
4mod edit;
5mod generate;
6mod instructions;
7mod protocol;
8mod read;
9mod request;
10mod session;
11mod task;
12mod tools;
13mod traffic;
14
15use std::fmt::Write as _;
16use std::io::{self, BufRead, Write};
17use std::path::Path;
18
19use crate::child_process::stop_on_interrupt;
20use crate::gen_command::GenError;
21pub(crate) use cargo::build_failure;
22
23const MAX_ANSWER: usize = 16 * 1024;
25
26pub fn serve_mcp(root: &Path, input: impl BufRead, mut output: impl Write) -> io::Result<()> {
28 let root = root.canonicalize().map_err(|error| {
29 io::Error::new(
30 error.kind(),
31 format!("the app root {} cannot be read: {error}", root.display()),
32 )
33 })?;
34 stop_on_interrupt()?;
35 let mut session = session::DevSession::new(&root);
36 for line in input.split(b'\n') {
37 let line = line?;
38 let response = match String::from_utf8(line) {
39 Ok(line) if line.trim().is_empty() => continue,
40 Ok(line) => protocol::answer(&root, &mut session, &line),
41 Err(_) => Some(protocol::not_utf8()),
42 };
43 if let Some(response) = response {
44 writeln!(output, "{response}")?;
45 output.flush()?;
46 }
47 }
48 Ok(())
49}
50
51pub(crate) struct ToolOutput {
52 text: String,
53 failed: bool,
54 rest: Option<String>,
56}
57
58impl ToolOutput {
59 fn done(text: String) -> Self {
60 Self::judged(text, true)
61 }
62
63 fn failed(text: String) -> Self {
64 Self::judged(text, false)
65 }
66
67 fn judged(text: String, succeeded: bool) -> Self {
68 Self {
69 text,
70 failed: !succeeded,
71 rest: None,
72 }
73 }
74
75 fn with_rest(mut self, rest: String) -> Self {
76 self.rest = Some(rest);
77 self
78 }
79
80 fn finished(mut self, root: &Path) -> Self {
82 let shown = root.display().to_string();
83 let relative = self
84 .text
85 .replace(&format!("{shown}/"), "")
86 .replace(&shown, ".");
87 self.text = bounded(&relative, self.rest.as_deref());
88 self
89 }
90
91 fn from_error(root: &Path, error: &GenError) -> Self {
92 match error {
93 GenError::Check(findings) => Self::failed(sofie_check::report(root, findings)),
94 GenError::Build(diagnostics) => {
95 Self::failed(diagnostics.clone()).with_rest(cargo::build_rest())
96 }
97 other => Self::failed(other.to_string()),
98 }
99 }
100}
101
102fn bounded(text: &str, rest: Option<&str>) -> String {
104 if text.len() <= MAX_ANSWER {
105 return text.to_owned();
106 }
107 let end = text.floor_char_boundary(MAX_ANSWER);
108 let mut cut = format!(
109 "{}\n… cut at {MAX_ANSWER} bytes of {}",
110 &text[..end],
111 text.len()
112 );
113 if let Some(rest) = rest {
114 let _ = write!(cut, "; run `{rest}` for the rest");
115 }
116 cut
117}
118
119#[cfg(test)]
120mod tests {
121 use std::path::Path;
122
123 use super::{MAX_ANSWER, ToolOutput, bounded};
124
125 #[test]
126 fn a_long_answer_keeps_its_hint_for_the_rest() {
127 let text = format!("{}\n", "/app/src/lib.rs: warning\n".repeat(MAX_ANSWER));
128
129 let output = ToolOutput::judged(text, false)
130 .with_rest("cargo test --offline".to_owned())
131 .finished(Path::new("/app"));
132
133 assert!(output.failed);
134 assert!(
135 output.text.starts_with("src/lib.rs: warning"),
136 "{}",
137 &output.text[..40]
138 );
139 assert!(
140 output
141 .text
142 .ends_with("run `cargo test --offline` for the rest"),
143 "{}",
144 &output.text[output.text.len() - 120..]
145 );
146 }
147
148 #[test]
149 fn a_long_answer_is_cut_at_a_character_boundary_and_says_so() {
150 let text = "é".repeat(MAX_ANSWER);
151
152 let cut = bounded(&text, Some("cargo test --offline"));
153
154 assert!(cut.len() < MAX_ANSWER + 200, "{}", cut.len());
155 assert!(
156 cut.ends_with("run `cargo test --offline` for the rest"),
157 "{cut}"
158 );
159 assert_eq!(bounded("short", None), "short");
160 }
161}