Skip to main content

codelens_engine/file_ops/
writer.rs

1//! Raw single-file write primitives.
2//!
3//! **Internal API — bypass-the-substrate warning.** Every function in
4//! this module performs an unconditional disk write through
5//! `apply_full_write_with_evidence`. None of them enforce
6//! ADR-0009 role gates, write audit rows, or invalidate engine
7//! caches. That contract lives in `codelens-mcp`'s
8//! `dispatch::session::apply_post_mutation`.
9//!
10//! Consumers must call these primitives only via `codelens-mcp`
11//! dispatch (HTTP / stdio MCP, or in-process `dispatch_tool`) —
12//! direct calls from third-party crates silently bypass the
13//! principals.toml configuration, the audit log, and downstream
14//! cache invalidation. See the crate-level docs in `lib.rs`.
15
16use crate::edit_transaction::{ApplyEvidence, apply_full_write_with_evidence};
17use crate::project::ProjectRoot;
18use anyhow::{Context, Result, bail};
19use regex::Regex;
20use std::fs;
21
22pub fn create_text_file(
23    project: &ProjectRoot,
24    relative_path: &str,
25    content: &str,
26    overwrite: bool,
27) -> Result<ApplyEvidence> {
28    let resolved = project.resolve(relative_path)?;
29    if !overwrite && resolved.exists() {
30        bail!("file already exists: {}", resolved.display());
31    }
32    if let Some(parent) = resolved.parent() {
33        fs::create_dir_all(parent)
34            .with_context(|| format!("failed to create directories for {}", resolved.display()))?;
35    }
36    let evidence = match apply_full_write_with_evidence(project, relative_path, content) {
37        Ok(ev) => ev,
38        Err(crate::edit_transaction::ApplyError::ApplyFailed {
39            source: _,
40            evidence,
41        }) => {
42            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
43            // translates this to Ok response with apply_status="rolled_back" +
44            // error_message synthesised from rollback_report[].reason.
45            evidence
46        }
47        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
48    };
49    Ok(evidence)
50}
51
52pub fn delete_lines(
53    project: &ProjectRoot,
54    relative_path: &str,
55    start_line: usize,
56    end_line: usize,
57) -> Result<(String, ApplyEvidence)> {
58    let resolved = project.resolve(relative_path)?;
59    let content = fs::read_to_string(&resolved)
60        .with_context(|| format!("failed to read {}", resolved.display()))?;
61    let mut lines: Vec<&str> = content.lines().collect();
62    let total = lines.len();
63    if start_line < 1 || start_line > total + 1 {
64        bail!(
65            "start_line {} out of range (file has {} lines)",
66            start_line,
67            total
68        );
69    }
70    if end_line < start_line || end_line > total + 1 {
71        bail!("end_line {} out of range", end_line);
72    }
73    // Convert from 1-indexed inclusive-start/exclusive-end to 0-indexed
74    let from = start_line - 1;
75    let to = (end_line - 1).min(lines.len());
76    lines.drain(from..to);
77    let result = lines.join("\n");
78    // Preserve trailing newline if original had one
79    let result = if content.ends_with('\n') {
80        format!("{result}\n")
81    } else {
82        result
83    };
84    let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
85        Ok(ev) => ev,
86        Err(crate::edit_transaction::ApplyError::ApplyFailed {
87            source: _,
88            evidence,
89        }) => {
90            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
91            // translates this to Ok response with apply_status="rolled_back" +
92            // error_message synthesised from rollback_report[].reason.
93            evidence
94        }
95        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
96    };
97    Ok((result, evidence))
98}
99
100pub fn insert_at_line(
101    project: &ProjectRoot,
102    relative_path: &str,
103    line: usize,
104    content_to_insert: &str,
105) -> Result<(String, ApplyEvidence)> {
106    let resolved = project.resolve(relative_path)?;
107    let content = fs::read_to_string(&resolved)
108        .with_context(|| format!("failed to read {}", resolved.display()))?;
109    let mut lines: Vec<&str> = content.lines().collect();
110    let total = lines.len();
111    if line < 1 || line > total + 1 {
112        bail!("line {} out of range (file has {} lines)", line, total);
113    }
114    let insert_pos = line - 1;
115    let new_lines: Vec<&str> = content_to_insert.lines().collect();
116    for (i, new_line) in new_lines.iter().enumerate() {
117        lines.insert(insert_pos + i, new_line);
118    }
119    let result = lines.join("\n");
120    let result = if content.ends_with('\n') || content_to_insert.ends_with('\n') {
121        format!("{result}\n")
122    } else {
123        result
124    };
125    let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
126        Ok(ev) => ev,
127        Err(crate::edit_transaction::ApplyError::ApplyFailed {
128            source: _,
129            evidence,
130        }) => {
131            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
132            // translates this to Ok response with apply_status="rolled_back" +
133            // error_message synthesised from rollback_report[].reason.
134            evidence
135        }
136        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
137    };
138    Ok((result, evidence))
139}
140
141pub fn replace_lines(
142    project: &ProjectRoot,
143    relative_path: &str,
144    start_line: usize,
145    end_line: usize,
146    new_content: &str,
147) -> Result<(String, ApplyEvidence)> {
148    let resolved = project.resolve(relative_path)?;
149    let content = fs::read_to_string(&resolved)
150        .with_context(|| format!("failed to read {}", resolved.display()))?;
151    let mut lines: Vec<&str> = content.lines().collect();
152    let total = lines.len();
153    if start_line < 1 || start_line > total + 1 {
154        bail!(
155            "start_line {} out of range (file has {} lines)",
156            start_line,
157            total
158        );
159    }
160    if end_line < start_line || end_line > total + 1 {
161        bail!("end_line {} out of range", end_line);
162    }
163    let from = start_line - 1;
164    let to = (end_line - 1).min(lines.len());
165    lines.drain(from..to);
166    let replacement: Vec<&str> = new_content.lines().collect();
167    for (i, rep_line) in replacement.iter().enumerate() {
168        lines.insert(from + i, rep_line);
169    }
170    let result = lines.join("\n");
171    let result = if content.ends_with('\n') {
172        format!("{result}\n")
173    } else {
174        result
175    };
176    let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
177        Ok(ev) => ev,
178        Err(crate::edit_transaction::ApplyError::ApplyFailed {
179            source: _,
180            evidence,
181        }) => {
182            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
183            // translates this to Ok response with apply_status="rolled_back" +
184            // error_message synthesised from rollback_report[].reason.
185            evidence
186        }
187        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
188    };
189    Ok((result, evidence))
190}
191
192pub fn replace_content(
193    project: &ProjectRoot,
194    relative_path: &str,
195    old_text: &str,
196    new_text: &str,
197    regex_mode: bool,
198) -> Result<(String, usize, ApplyEvidence)> {
199    let resolved = project.resolve(relative_path)?;
200    let content = fs::read_to_string(&resolved)
201        .with_context(|| format!("failed to read {}", resolved.display()))?;
202    let (result, count) = if regex_mode {
203        let re = Regex::new(old_text).with_context(|| format!("invalid regex: {old_text}"))?;
204        let mut count = 0usize;
205        let replaced = re
206            .replace_all(&content, |_caps: &regex::Captures| {
207                count += 1;
208                new_text
209            })
210            .into_owned();
211        (replaced, count)
212    } else {
213        let count = content.matches(old_text).count();
214        let replaced = content.replace(old_text, new_text);
215        (replaced, count)
216    };
217    let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
218        Ok(ev) => ev,
219        Err(crate::edit_transaction::ApplyError::ApplyFailed {
220            source: _,
221            evidence,
222        }) => {
223            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
224            // translates this to Ok response with apply_status="rolled_back" +
225            // error_message synthesised from rollback_report[].reason.
226            evidence
227        }
228        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
229    };
230    Ok((result, count, evidence))
231}
232
233pub fn replace_symbol_body(
234    project: &ProjectRoot,
235    relative_path: &str,
236    symbol_name: &str,
237    name_path: Option<&str>,
238    new_body: &str,
239) -> Result<(String, ApplyEvidence)> {
240    let (start_byte, end_byte) =
241        crate::symbols::find_symbol_range(project, relative_path, symbol_name, name_path)?;
242    let resolved = project.resolve(relative_path)?;
243    let content = fs::read_to_string(&resolved)
244        .with_context(|| format!("failed to read {}", resolved.display()))?;
245    let bytes = content.as_bytes();
246    let mut buffer = Vec::with_capacity(bytes.len());
247    buffer.extend_from_slice(&bytes[..start_byte]);
248    buffer.extend_from_slice(new_body.as_bytes());
249    buffer.extend_from_slice(&bytes[end_byte..]);
250    let result =
251        String::from_utf8(buffer).with_context(|| "result is not valid UTF-8 after replacement")?;
252    let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
253        Ok(ev) => ev,
254        Err(crate::edit_transaction::ApplyError::ApplyFailed {
255            source: _,
256            evidence,
257        }) => {
258            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
259            // translates this to Ok response with apply_status="rolled_back" +
260            // error_message synthesised from rollback_report[].reason.
261            evidence
262        }
263        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
264    };
265    Ok((result, evidence))
266}
267
268pub fn insert_before_symbol(
269    project: &ProjectRoot,
270    relative_path: &str,
271    symbol_name: &str,
272    name_path: Option<&str>,
273    content_to_insert: &str,
274) -> Result<(String, ApplyEvidence)> {
275    let (start_byte, _) =
276        crate::symbols::find_symbol_range(project, relative_path, symbol_name, name_path)?;
277    let resolved = project.resolve(relative_path)?;
278    let content = fs::read_to_string(&resolved)
279        .with_context(|| format!("failed to read {}", resolved.display()))?;
280    let bytes = content.as_bytes();
281    let mut buffer = Vec::with_capacity(bytes.len() + content_to_insert.len());
282    buffer.extend_from_slice(&bytes[..start_byte]);
283    buffer.extend_from_slice(content_to_insert.as_bytes());
284    buffer.extend_from_slice(&bytes[start_byte..]);
285    let result =
286        String::from_utf8(buffer).with_context(|| "result is not valid UTF-8 after insertion")?;
287    let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
288        Ok(ev) => ev,
289        Err(crate::edit_transaction::ApplyError::ApplyFailed {
290            source: _,
291            evidence,
292        }) => {
293            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
294            // translates this to Ok response with apply_status="rolled_back" +
295            // error_message synthesised from rollback_report[].reason.
296            evidence
297        }
298        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
299    };
300    Ok((result, evidence))
301}
302
303pub fn insert_after_symbol(
304    project: &ProjectRoot,
305    relative_path: &str,
306    symbol_name: &str,
307    name_path: Option<&str>,
308    content_to_insert: &str,
309) -> Result<(String, ApplyEvidence)> {
310    let (_, end_byte) =
311        crate::symbols::find_symbol_range(project, relative_path, symbol_name, name_path)?;
312    let resolved = project.resolve(relative_path)?;
313    let content = fs::read_to_string(&resolved)
314        .with_context(|| format!("failed to read {}", resolved.display()))?;
315    let bytes = content.as_bytes();
316    let mut buffer = Vec::with_capacity(bytes.len() + content_to_insert.len());
317    buffer.extend_from_slice(&bytes[..end_byte]);
318    buffer.extend_from_slice(content_to_insert.as_bytes());
319    buffer.extend_from_slice(&bytes[end_byte..]);
320    let result =
321        String::from_utf8(buffer).with_context(|| "result is not valid UTF-8 after insertion")?;
322    let evidence = match apply_full_write_with_evidence(project, relative_path, &result) {
323        Ok(ev) => ev,
324        Err(crate::edit_transaction::ApplyError::ApplyFailed {
325            source: _,
326            evidence,
327        }) => {
328            // Hybrid: status=RolledBack signals fail-closed; mcp tool handler
329            // translates this to Ok response with apply_status="rolled_back" +
330            // error_message synthesised from rollback_report[].reason.
331            evidence
332        }
333        Err(other) => return Err(anyhow::Error::msg(other.to_string())),
334    };
335    Ok((result, evidence))
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::edit_transaction::ApplyStatus;
342    use crate::project::ProjectRoot;
343
344    fn make_project(dir: &std::path::Path) -> ProjectRoot {
345        ProjectRoot::new(dir.to_str().unwrap()).unwrap()
346    }
347
348    fn temp_dir_with_name(name: &str) -> std::path::PathBuf {
349        let dir = std::env::temp_dir().join(format!(
350            "codelens-writer-{}-{}-{}",
351            name,
352            std::process::id(),
353            std::time::SystemTime::now()
354                .duration_since(std::time::UNIX_EPOCH)
355                .unwrap()
356                .as_nanos()
357        ));
358        std::fs::create_dir_all(&dir).unwrap();
359        dir
360    }
361
362    #[test]
363    fn replace_lines_evidence_post_apply_hash_matches_disk() {
364        use sha2::{Digest, Sha256};
365
366        let dir = temp_dir_with_name("evidence");
367        let project = make_project(&dir);
368        std::fs::write(dir.join("doc.txt"), "line1\nline2\nline3\n").unwrap();
369
370        let (content, evidence) = replace_lines(&project, "doc.txt", 2, 3, "REPLACED\n").unwrap();
371        assert!(content.contains("REPLACED"));
372        assert_eq!(evidence.status, ApplyStatus::Applied);
373        assert_eq!(evidence.modified_files, 1);
374        assert_eq!(evidence.edit_count, 1);
375
376        let on_disk = std::fs::read(dir.join("doc.txt")).unwrap();
377        let mut hasher = Sha256::new();
378        hasher.update(&on_disk);
379        let mut hex = String::with_capacity(64);
380        for byte in hasher.finalize() {
381            use std::fmt::Write as _;
382            let _ = write!(hex, "{byte:02x}");
383        }
384        let evidence_hash = &evidence.file_hashes_after["doc.txt"].sha256;
385        assert_eq!(
386            evidence_hash, &hex,
387            "evidence post-apply hash must match disk content"
388        );
389    }
390}