Skip to main content

pawan/tools/
file.rs

1//! File read/write tools with safety validation
2
3use super::Tool;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::{Path, PathBuf};
7
8/// Validate a file path for write safety.
9/// Returns Err with reason if the write should be blocked.
10/// Inspired by claw-code's file_ops.rs safety checks.
11pub fn validate_file_write(path: &Path) -> Result<(), &'static str> {
12    let path_str = path.to_string_lossy();
13    let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
14
15    // Block: writes inside .git directory (corrupts repository)
16    for component in path.components() {
17        if let std::path::Component::Normal(c) = component {
18            if c == ".git" {
19                return Err("refuses to write inside .git directory");
20            }
21        }
22    }
23
24    // Block: sensitive credential/secret files
25    let blocked_files = [
26        ".env",
27        ".env.local",
28        ".env.production",
29        "id_rsa",
30        "id_ed25519",
31        "id_ecdsa",
32        "credentials.json",
33        "service-account.json",
34        ".npmrc",
35        ".pypirc",
36    ];
37    if blocked_files.contains(&filename) {
38        return Err("refuses to overwrite credential/secret file");
39    }
40
41    // Block: critical system paths
42    if path_str.starts_with("/etc/")
43        || path_str.starts_with("/usr/")
44        || path_str.starts_with("/bin/")
45        || path_str.starts_with("/sbin/")
46        || path_str.starts_with("/boot/")
47    {
48        return Err("refuses to write to system directory");
49    }
50
51    // Warn-level (allow but log): lock files
52    let warn_files = [
53        "Cargo.lock",
54        "package-lock.json",
55        "yarn.lock",
56        "pnpm-lock.yaml",
57        "Gemfile.lock",
58        "poetry.lock",
59    ];
60    if warn_files.contains(&filename) {
61        tracing::warn!(path = %path_str, "Writing to lock file — usually auto-generated");
62    }
63
64    Ok(())
65}
66
67/// Normalize a path relative to the workspace root.
68///
69/// Handles the double-prefix bug where the model passes an absolute path
70/// like "/home/user/ws/home/user/ws/foo.rs" — it joined the workspace
71/// root with an absolute path instead of a relative one. We detect the
72/// workspace root appearing twice and collapse to the second occurrence.
73///
74/// # Parameters
75/// - `workspace_root`: The root directory of the workspace
76/// - `path`: The path to normalize (can be relative or absolute)
77///
78/// # Returns
79/// The normalized path as a PathBuf
80pub fn normalize_path(workspace_root: &Path, path: &str) -> PathBuf {
81    let p = PathBuf::from(path);
82    if p.is_absolute() {
83        let ws = workspace_root.to_string_lossy();
84        let ps = p.to_string_lossy();
85        // If path starts with workspace_root, check if ws appears again in the remainder
86        if ps.starts_with(&*ws) {
87            let tail = &ps[ws.len()..];
88            if let Some(idx) = tail.find(&*ws) {
89                let corrected = &tail[idx..];
90                tracing::warn!(
91                    original = %ps, corrected = %corrected,
92                    "Path normalization: double workspace prefix detected"
93                );
94                return PathBuf::from(corrected.to_string());
95            }
96        }
97        p
98    } else {
99        workspace_root.join(p)
100    }
101}
102
103/// Tool for reading file contents
104pub struct ReadFileTool {
105    workspace_root: PathBuf,
106}
107
108impl ReadFileTool {
109    pub fn new(workspace_root: PathBuf) -> Self {
110        Self { workspace_root }
111    }
112
113    fn resolve_path(&self, path: &str) -> PathBuf {
114        normalize_path(&self.workspace_root, path)
115    }
116}
117
118#[async_trait]
119impl Tool for ReadFileTool {
120    fn name(&self) -> &str {
121        "read_file"
122    }
123
124    fn description(&self) -> &str {
125        "Read the contents of a file. Returns the file content with line numbers."
126    }
127
128    fn parameters_schema(&self) -> Value {
129        json!({
130            "type": "object",
131            "properties": {
132                "path": {
133                    "type": "string",
134                    "description": "Path to the file to read (relative to workspace root or absolute)"
135                },
136                "offset": {
137                    "type": "integer",
138                    "description": "Line number to start reading from (0-based, optional)"
139                },
140                "limit": {
141                    "type": "integer",
142                    "description": "Maximum number of lines to read (optional, defaults to 2000)"
143                }
144            },
145            "required": ["path"]
146        })
147    }
148
149    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
150        use thulp_core::{Parameter, ParameterType};
151        thulp_core::ToolDefinition::builder("read_file")
152            .description(self.description())
153            .parameter(
154                Parameter::builder("path")
155                    .param_type(ParameterType::String)
156                    .required(true)
157                    .description(
158                        "Path to the file to read (relative to workspace root or absolute)",
159                    )
160                    .build(),
161            )
162            .parameter(
163                Parameter::builder("offset")
164                    .param_type(ParameterType::Integer)
165                    .required(false)
166                    .description("Line number to start reading from (0-based, optional)")
167                    .build(),
168            )
169            .parameter(
170                Parameter::builder("limit")
171                    .param_type(ParameterType::Integer)
172                    .required(false)
173                    .description("Maximum number of lines to read (optional, defaults to 2000)")
174                    .build(),
175            )
176            .build()
177    }
178
179    async fn execute(&self, args: Value) -> crate::Result<Value> {
180        let path = args["path"]
181            .as_str()
182            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
183
184        let offset = args["offset"].as_u64().unwrap_or(0) as usize;
185        let limit = args["limit"].as_u64().unwrap_or(200) as usize;
186
187        let full_path = self.resolve_path(path);
188
189        if !full_path.exists() {
190            return Err(crate::PawanError::NotFound(format!(
191                "File not found: {}",
192                full_path.display()
193            )));
194        }
195
196        let content = tokio::fs::read_to_string(&full_path)
197            .await
198            .map_err(crate::PawanError::Io)?;
199
200        let lines: Vec<&str> = content.lines().collect();
201        let total_lines = lines.len();
202
203        let selected_lines: Vec<String> = lines
204            .into_iter()
205            .skip(offset)
206            .take(limit)
207            .enumerate()
208            .map(|(i, line)| {
209                let line_num = offset + i + 1;
210                // Truncate very long lines
211                let display_line = if line.len() > 2000 {
212                    format!("{}...[truncated]", &line[..2000])
213                } else {
214                    line.to_string()
215                };
216                format!("{:>6}\t{}", line_num, display_line)
217            })
218            .collect();
219
220        let output = selected_lines.join("\n");
221
222        let warning = if total_lines > 300 && selected_lines.len() == total_lines {
223            Some(format!(
224                "Large file ({} lines). Consider using offset/limit to read specific sections, \
225                 or use anchor_text in edit_file_lines to avoid line-number math.",
226                total_lines
227            ))
228        } else {
229            None
230        };
231
232        Ok(json!({
233            "content": output,
234            "path": full_path.display().to_string(),
235            "total_lines": total_lines,
236            "lines_shown": selected_lines.len(),
237            "offset": offset,
238            "warning": warning
239        }))
240    }
241}
242
243/// Tool for writing file contents
244pub struct WriteFileTool {
245    workspace_root: PathBuf,
246}
247
248impl WriteFileTool {
249    pub fn new(workspace_root: PathBuf) -> Self {
250        Self { workspace_root }
251    }
252
253    fn resolve_path(&self, path: &str) -> PathBuf {
254        normalize_path(&self.workspace_root, path)
255    }
256}
257
258#[async_trait]
259impl Tool for WriteFileTool {
260    fn name(&self) -> &str {
261        "write_file"
262    }
263
264    fn description(&self) -> &str {
265        "Write content to a file. Creates parent directories automatically. \
266         PREFER edit_file or edit_file_lines for modifying existing files — \
267         write_file overwrites the entire file. Only use for creating new files \
268         or complete rewrites. Writes to .git/, .env, credential files, and \
269         system paths (/etc, /usr) are blocked for safety."
270    }
271
272    fn parameters_schema(&self) -> Value {
273        json!({
274            "type": "object",
275            "properties": {
276                "path": {
277                    "type": "string",
278                    "description": "Path to the file to write (relative to workspace root or absolute)"
279                },
280                "content": {
281                    "type": "string",
282                    "description": "Content to write to the file"
283                }
284            },
285            "required": ["path", "content"]
286        })
287    }
288
289    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
290        use thulp_core::{Parameter, ParameterType};
291        thulp_core::ToolDefinition::builder("write_file")
292            .description(self.description())
293            .parameter(
294                Parameter::builder("path")
295                    .param_type(ParameterType::String)
296                    .required(true)
297                    .description(
298                        "Path to the file to write (relative to workspace root or absolute)",
299                    )
300                    .build(),
301            )
302            .parameter(
303                Parameter::builder("content")
304                    .param_type(ParameterType::String)
305                    .required(true)
306                    .description("Content to write to the file")
307                    .build(),
308            )
309            .build()
310    }
311
312    async fn execute(&self, args: Value) -> crate::Result<Value> {
313        let path = args["path"]
314            .as_str()
315            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
316
317        let content = args["content"]
318            .as_str()
319            .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
320
321        let full_path = self.resolve_path(path);
322
323        // Validate write safety
324        if let Err(reason) = validate_file_write(&full_path) {
325            return Err(crate::PawanError::Tool(format!(
326                "Write blocked: {} — {}",
327                full_path.display(),
328                reason
329            )));
330        }
331
332        // Create parent directories if needed
333        if let Some(parent) = full_path.parent() {
334            tokio::fs::create_dir_all(parent)
335                .await
336                .map_err(crate::PawanError::Io)?;
337        }
338
339        // Write the file
340        tokio::fs::write(&full_path, content)
341            .await
342            .map_err(crate::PawanError::Io)?;
343
344        // Verify written size matches expected
345        let written_size = tokio::fs::metadata(&full_path)
346            .await
347            .map(|m| m.len() as usize)
348            .unwrap_or(0);
349        let line_count = content.lines().count();
350        let size_mismatch = written_size != content.len();
351
352        Ok(json!({
353            "success": true,
354            "path": full_path.display().to_string(),
355            "bytes_written": content.len(),
356            "bytes_on_disk": written_size,
357            "size_verified": !size_mismatch,
358            "lines": line_count
359        }))
360    }
361}
362
363/// Tool for listing directory contents
364pub struct ListDirectoryTool {
365    workspace_root: PathBuf,
366}
367
368impl ListDirectoryTool {
369    pub fn new(workspace_root: PathBuf) -> Self {
370        Self { workspace_root }
371    }
372
373    fn resolve_path(&self, path: &str) -> PathBuf {
374        normalize_path(&self.workspace_root, path)
375    }
376}
377
378#[async_trait]
379impl Tool for ListDirectoryTool {
380    fn name(&self) -> &str {
381        "list_directory"
382    }
383
384    fn description(&self) -> &str {
385        "List the contents of a directory."
386    }
387
388    fn parameters_schema(&self) -> Value {
389        json!({
390            "type": "object",
391            "properties": {
392                "path": {
393                    "type": "string",
394                    "description": "Path to the directory to list (relative to workspace root or absolute)"
395                },
396                "recursive": {
397                    "type": "boolean",
398                    "description": "Whether to list recursively (default: false)"
399                },
400                "max_depth": {
401                    "type": "integer",
402                    "description": "Maximum depth for recursive listing (default: 3)"
403                }
404            },
405            "required": ["path"]
406        })
407    }
408
409    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
410        use thulp_core::{Parameter, ParameterType};
411        thulp_core::ToolDefinition::builder("list_directory")
412            .description(self.description())
413            .parameter(
414                Parameter::builder("path")
415                    .param_type(ParameterType::String)
416                    .required(true)
417                    .description(
418                        "Path to the directory to list (relative to workspace root or absolute)",
419                    )
420                    .build(),
421            )
422            .parameter(
423                Parameter::builder("recursive")
424                    .param_type(ParameterType::Boolean)
425                    .required(false)
426                    .description("Whether to list recursively (default: false)")
427                    .build(),
428            )
429            .parameter(
430                Parameter::builder("max_depth")
431                    .param_type(ParameterType::Integer)
432                    .required(false)
433                    .description("Maximum depth for recursive listing (default: 3)")
434                    .build(),
435            )
436            .build()
437    }
438
439    async fn execute(&self, args: Value) -> crate::Result<Value> {
440        let path = args["path"]
441            .as_str()
442            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
443
444        let recursive = args["recursive"].as_bool().unwrap_or(false);
445        let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize;
446
447        let full_path = self.resolve_path(path);
448
449        if !full_path.exists() {
450            return Err(crate::PawanError::NotFound(format!(
451                "Directory not found: {}",
452                full_path.display()
453            )));
454        }
455
456        if !full_path.is_dir() {
457            return Err(crate::PawanError::Tool(format!(
458                "Not a directory: {}",
459                full_path.display()
460            )));
461        }
462
463        let mut entries = Vec::new();
464
465        if recursive {
466            for entry in walkdir::WalkDir::new(&full_path)
467                .max_depth(max_depth)
468                .into_iter()
469                .filter_map(|e| e.ok())
470            {
471                let path = entry.path();
472                let relative = path.strip_prefix(&full_path).unwrap_or(path);
473                let is_dir = entry.file_type().is_dir();
474                let size = if is_dir {
475                    0
476                } else {
477                    entry.metadata().map(|m| m.len()).unwrap_or(0)
478                };
479
480                entries.push(json!({
481                    "path": relative.display().to_string(),
482                    "is_dir": is_dir,
483                    "size": size
484                }));
485            }
486        } else {
487            let mut read_dir = tokio::fs::read_dir(&full_path)
488                .await
489                .map_err(crate::PawanError::Io)?;
490
491            while let Some(entry) = read_dir.next_entry().await.map_err(crate::PawanError::Io)? {
492                let path = entry.path();
493                let name = entry.file_name().to_string_lossy().to_string();
494                let metadata = entry.metadata().await.ok();
495                let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
496                let size = metadata.map(|m| m.len()).unwrap_or(0);
497
498                entries.push(json!({
499                    "name": name,
500                    "path": path.display().to_string(),
501                    "is_dir": is_dir,
502                    "size": size
503                }));
504            }
505        }
506
507        Ok(json!({
508            "path": full_path.display().to_string(),
509            "entries": entries,
510            "count": entries.len()
511        }))
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use tempfile::TempDir;
519
520    #[tokio::test]
521    async fn test_read_file() {
522        let temp_dir = TempDir::new().unwrap();
523        let file_path = temp_dir.path().join("test.txt");
524        std::fs::write(&file_path, "line 1\nline 2\nline 3").unwrap();
525
526        let tool = ReadFileTool::new(temp_dir.path().to_path_buf());
527        let result = tool.execute(json!({"path": "test.txt"})).await.unwrap();
528
529        assert_eq!(result["total_lines"], 3);
530        assert!(result["content"].as_str().unwrap().contains("line 1"));
531    }
532
533    #[tokio::test]
534    async fn test_write_file() {
535        let temp_dir = TempDir::new().unwrap();
536
537        let tool = WriteFileTool::new(temp_dir.path().to_path_buf());
538        let result = tool
539            .execute(json!({
540                "path": "new_file.txt",
541                "content": "hello\nworld"
542            }))
543            .await
544            .unwrap();
545
546        assert!(result["success"].as_bool().unwrap());
547        assert_eq!(result["lines"], 2);
548
549        let content = std::fs::read_to_string(temp_dir.path().join("new_file.txt")).unwrap();
550        assert_eq!(content, "hello\nworld");
551    }
552
553    // ─── ReadFileTool edge cases ──────────────────────────────────────────
554
555    #[tokio::test]
556    async fn test_read_file_missing_path_returns_error() {
557        let temp_dir = TempDir::new().unwrap();
558        let tool = ReadFileTool::new(temp_dir.path().to_path_buf());
559        let err = tool.execute(json!({})).await.unwrap_err();
560        match err {
561            crate::PawanError::Tool(msg) => assert!(msg.contains("path is required")),
562            other => panic!("expected Tool error, got {:?}", other),
563        }
564    }
565
566    #[tokio::test]
567    async fn test_read_file_nonexistent_returns_not_found() {
568        let temp_dir = TempDir::new().unwrap();
569        let tool = ReadFileTool::new(temp_dir.path().to_path_buf());
570        let err = tool
571            .execute(json!({"path": "does_not_exist.rs"}))
572            .await
573            .unwrap_err();
574        match err {
575            crate::PawanError::NotFound(msg) => assert!(msg.contains("File not found")),
576            other => panic!("expected NotFound error, got {:?}", other),
577        }
578    }
579
580    #[tokio::test]
581    async fn test_read_file_line_numbers_are_formatted() {
582        let temp_dir = TempDir::new().unwrap();
583        let file_path = temp_dir.path().join("numbered.txt");
584        std::fs::write(&file_path, "alpha\nbeta\ngamma").unwrap();
585
586        let tool = ReadFileTool::new(temp_dir.path().to_path_buf());
587        let result = tool.execute(json!({"path": "numbered.txt"})).await.unwrap();
588
589        let content = result["content"].as_str().unwrap();
590        // Line 1 must be right-aligned in a 6-char field followed by a tab
591        assert!(
592            content.contains("     1\talpha"),
593            "expected 6-char right-aligned line number: got {content:?}"
594        );
595        assert!(content.contains("     2\tbeta"));
596        assert!(content.contains("     3\tgamma"));
597    }
598
599    #[tokio::test]
600    async fn test_read_file_offset_and_limit_respected() {
601        let temp_dir = TempDir::new().unwrap();
602        let lines: String = (1..=10).map(|i| format!("line{i}\n")).collect();
603        let file_path = temp_dir.path().join("ten.txt");
604        std::fs::write(&file_path, &lines).unwrap();
605
606        let tool = ReadFileTool::new(temp_dir.path().to_path_buf());
607        // offset=3 skips lines 1-3, limit=2 takes lines 4 and 5
608        let result = tool
609            .execute(json!({"path": "ten.txt", "offset": 3, "limit": 2}))
610            .await
611            .unwrap();
612
613        assert_eq!(result["lines_shown"], 2);
614        assert_eq!(result["offset"], 3);
615        let content = result["content"].as_str().unwrap();
616        // Line 4 and 5 must appear; lines 1-3 and 6-10 must not
617        assert!(content.contains("line4"), "expected line4 in {content:?}");
618        assert!(content.contains("line5"), "expected line5 in {content:?}");
619        assert!(!content.contains("line3"), "line3 should be before offset");
620        assert!(!content.contains("line6"), "line6 should be beyond limit");
621    }
622
623    #[tokio::test]
624    async fn test_read_file_large_file_warning() {
625        let temp_dir = TempDir::new().unwrap();
626        // 301 lines — triggers the large-file warning when all are read
627        let lines: String = (1..=301).map(|i| format!("ln{i}\n")).collect();
628        let file_path = temp_dir.path().join("large.txt");
629        std::fs::write(&file_path, &lines).unwrap();
630
631        let tool = ReadFileTool::new(temp_dir.path().to_path_buf());
632        // Read without offset/limit — default limit is 200, so lines_shown < total_lines
633        // Warning fires only when lines_shown == total_lines and total_lines > 300,
634        // so use a large limit to force all lines to be shown.
635        let result = tool
636            .execute(json!({"path": "large.txt", "limit": 400}))
637            .await
638            .unwrap();
639
640        assert_eq!(result["total_lines"], 301);
641        let warning = &result["warning"];
642        assert!(
643            !warning.is_null(),
644            "expected warning for 301-line file, got null"
645        );
646        assert!(
647            warning.as_str().unwrap().contains("Large file"),
648            "warning should mention 'Large file'"
649        );
650    }
651
652    // ─── WriteFileTool edge cases ─────────────────────────────────────────
653
654    #[tokio::test]
655    async fn test_write_file_missing_path_returns_error() {
656        let temp_dir = TempDir::new().unwrap();
657        let tool = WriteFileTool::new(temp_dir.path().to_path_buf());
658        let err = tool.execute(json!({"content": "hello"})).await.unwrap_err();
659        match err {
660            crate::PawanError::Tool(msg) => assert!(msg.contains("path is required")),
661            other => panic!("expected Tool error, got {:?}", other),
662        }
663    }
664
665    #[tokio::test]
666    async fn test_write_file_missing_content_returns_error() {
667        let temp_dir = TempDir::new().unwrap();
668        let tool = WriteFileTool::new(temp_dir.path().to_path_buf());
669        let err = tool
670            .execute(json!({"path": "output.txt"}))
671            .await
672            .unwrap_err();
673        match err {
674            crate::PawanError::Tool(msg) => assert!(msg.contains("content is required")),
675            other => panic!("expected Tool error, got {:?}", other),
676        }
677    }
678
679    #[tokio::test]
680    async fn test_write_file_blocked_dotgit_returns_error() {
681        let temp_dir = TempDir::new().unwrap();
682        // Use an absolute .git path under temp so validate_file_write sees the .git component
683        let git_path = temp_dir.path().join(".git").join("COMMIT_EDITMSG");
684        let tool = WriteFileTool::new(temp_dir.path().to_path_buf());
685        let err = tool
686            .execute(json!({"path": git_path.to_str().unwrap(), "content": "blocked"}))
687            .await
688            .unwrap_err();
689        match err {
690            crate::PawanError::Tool(msg) => {
691                assert!(
692                    msg.contains("Write blocked"),
693                    "expected 'Write blocked' in: {msg}"
694                );
695                assert!(msg.contains(".git"), "expected '.git' in: {msg}");
696            }
697            other => panic!("expected Tool error, got {:?}", other),
698        }
699    }
700
701    #[tokio::test]
702    async fn test_list_directory() {
703        let temp_dir = TempDir::new().unwrap();
704        std::fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
705        std::fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
706        std::fs::create_dir(temp_dir.path().join("subdir")).unwrap();
707
708        let tool = ListDirectoryTool::new(temp_dir.path().to_path_buf());
709        let result = tool.execute(json!({"path": "."})).await.unwrap();
710
711        assert_eq!(result["count"], 3);
712    }
713
714    #[test]
715    fn test_normalize_path_double_prefix() {
716        let ws = PathBuf::from("/home/user/workspace");
717        // Model passes absolute path with workspace root repeated
718        let bad = "/home/user/workspace/home/user/workspace/leftist_heap/src/lib.rs";
719        let result = normalize_path(&ws, bad);
720        assert_eq!(
721            result,
722            PathBuf::from("/home/user/workspace/leftist_heap/src/lib.rs")
723        );
724    }
725
726    #[test]
727    fn test_normalize_path_normal_absolute() {
728        let ws = PathBuf::from("/home/user/workspace");
729        let normal = "/home/user/workspace/trie/src/lib.rs";
730        let result = normalize_path(&ws, normal);
731        assert_eq!(
732            result,
733            PathBuf::from("/home/user/workspace/trie/src/lib.rs")
734        );
735    }
736
737    #[test]
738    fn test_normalize_path_relative() {
739        let ws = PathBuf::from("/home/user/workspace");
740        let rel = "trie/src/lib.rs";
741        let result = normalize_path(&ws, rel);
742        assert_eq!(
743            result,
744            PathBuf::from("/home/user/workspace/trie/src/lib.rs")
745        );
746    }
747
748    #[test]
749    fn test_normalize_path_unrelated_absolute() {
750        let ws = PathBuf::from("/home/user/workspace");
751        let other = "/tmp/foo/bar.rs";
752        let result = normalize_path(&ws, other);
753        assert_eq!(result, PathBuf::from("/tmp/foo/bar.rs"));
754    }
755
756    // --- validate_file_write security tests (previously ZERO coverage) ---
757
758    #[test]
759    fn test_validate_file_write_blocks_dotgit_writes() {
760        // .git anywhere in path should be blocked — would corrupt repo state
761        let cases = [
762            "/home/user/repo/.git/HEAD",
763            "/opt/app/.git/config",
764            ".git/index",
765            "./.git/hooks/pre-commit",
766            "/tmp/foo/.git/something",
767        ];
768        for p in cases {
769            let result = validate_file_write(Path::new(p));
770            assert!(result.is_err(), "Expected .git write to be blocked: {}", p);
771            assert!(result.unwrap_err().contains(".git"));
772        }
773    }
774
775    #[test]
776    fn test_validate_file_write_blocks_credential_files() {
777        let blocked = [
778            ".env",
779            ".env.local",
780            ".env.production",
781            "id_rsa",
782            "id_ed25519",
783            "id_ecdsa",
784            "credentials.json",
785            "service-account.json",
786            ".npmrc",
787            ".pypirc",
788        ];
789        for name in blocked {
790            let path = PathBuf::from(format!("/tmp/test-dir/{}", name));
791            let result = validate_file_write(&path);
792            assert!(
793                result.is_err(),
794                "Expected {} to be blocked as credential file",
795                name
796            );
797            assert!(
798                result.unwrap_err().contains("credential"),
799                "Expected error to mention 'credential' for {}",
800                name
801            );
802        }
803    }
804
805    #[test]
806    fn test_validate_file_write_blocks_system_paths() {
807        let system_paths = [
808            "/etc/passwd",
809            "/etc/hosts",
810            "/usr/bin/myscript",
811            "/usr/local/bin/foo",
812            "/bin/sh",
813            "/sbin/init",
814            "/boot/vmlinuz",
815        ];
816        for p in system_paths {
817            let result = validate_file_write(Path::new(p));
818            assert!(result.is_err(), "Expected system path {} to be blocked", p);
819            assert!(result.unwrap_err().contains("system directory"));
820        }
821    }
822
823    #[test]
824    fn test_validate_file_write_allows_normal_paths() {
825        // Normal paths in the workspace should pass cleanly
826        let allowed = [
827            "/home/user/ws/src/main.rs",
828            "/tmp/scratch/notes.md",
829            "/opt/app/README.md",
830            "/var/tmp/output.txt",
831            "./relative/path/file.txt",
832        ];
833        for p in allowed {
834            let result = validate_file_write(Path::new(p));
835            assert!(
836                result.is_ok(),
837                "Expected {} to be allowed, got error: {:?}",
838                p,
839                result.err()
840            );
841        }
842    }
843
844    #[test]
845    fn test_validate_file_write_allows_lock_files_with_warn() {
846        // Lock files are warn-level, not blocked — they should return Ok
847        // but trigger the warn tracing event. We can't assert the warn
848        // emission in a unit test without a tracing subscriber, but we can
849        // assert Ok so the behavior contract is pinned.
850        let lock_files = [
851            "/home/user/ws/Cargo.lock",
852            "/home/user/ws/package-lock.json",
853            "/home/user/ws/yarn.lock",
854            "/home/user/ws/pnpm-lock.yaml",
855            "/home/user/ws/Gemfile.lock",
856            "/home/user/ws/poetry.lock",
857        ];
858        for p in lock_files {
859            let result = validate_file_write(Path::new(p));
860            assert!(
861                result.is_ok(),
862                "Lock file {} should be allowed (warn only), got error: {:?}",
863                p,
864                result.err()
865            );
866        }
867    }
868
869    // ─── Edge case tests for validate_file_write ─────────────────────────
870
871    #[test]
872    fn test_validate_file_write_allows_gitignore_not_blocked_as_dotgit() {
873        // Regression: `.gitignore`, `.github/`, `.git-credentials` must not
874        // be mistaken for the `.git/` directory component. Only an exact
875        // `.git` path component (delimited by `/`) should trigger the block.
876        let allowed = [
877            "/home/user/ws/.gitignore",
878            "/home/user/ws/.gitattributes",
879            "/home/user/ws/.github/workflows/ci.yml",
880            "/home/user/ws/.git-credentials",
881            "/home/user/ws/src/.gitkeep",
882        ];
883        for p in allowed {
884            let result = validate_file_write(Path::new(p));
885            assert!(
886                result.is_ok(),
887                "Path {} starts with .git but is NOT a .git component — should be allowed, got: {:?}",
888                p,
889                result.err()
890            );
891        }
892    }
893
894    #[test]
895    fn test_validate_file_write_case_sensitivity_on_env_files() {
896        // Filename comparison is case-sensitive (contains()) — .ENV and .Env
897        // are NOT blocked because blocked_files only lists lowercase. This
898        // test pins the current behavior so it's not accidentally changed
899        // without considering the impact on case-insensitive filesystems.
900        let path = PathBuf::from("/tmp/project/.ENV");
901        let result = validate_file_write(&path);
902        assert!(
903            result.is_ok(),
904            ".ENV (uppercase) is not in the blocked list — current behavior is to allow"
905        );
906    }
907
908    #[test]
909    fn test_validate_file_write_blocks_dotgit_even_at_root() {
910        // .git at the root of a path (no parent dirs) should still be blocked
911        let result = validate_file_write(Path::new(".git/HEAD"));
912        assert!(result.is_err(), "root-level .git/ must be blocked");
913        assert!(result.unwrap_err().contains(".git"));
914    }
915
916    #[test]
917    fn test_validate_file_write_handles_empty_filename() {
918        // A path that is just a directory (e.g. "/foo/bar/") has no filename.
919        // validate_file_write must not panic — it should treat it as allowed
920        // since there's no filename to match against the blocked list.
921        let result = validate_file_write(Path::new("/tmp/somedir/"));
922        assert!(
923            result.is_ok(),
924            "directory path with no filename must not panic or error"
925        );
926    }
927
928    #[test]
929    fn test_validate_file_write_allows_etc_files_at_wrong_level() {
930        // `/etc/` prefix blocks writes to /etc. But a path like
931        // "/home/user/etc/config" should NOT be blocked — /etc/ is the
932        // start-of-string match, not a substring.
933        let allowed = ["/home/user/etc/config.toml", "/opt/app/etc/overrides.yml"];
934        for p in allowed {
935            let result = validate_file_write(Path::new(p));
936            assert!(
937                result.is_ok(),
938                "Path {} with /etc/ not at start must be allowed",
939                p
940            );
941        }
942    }
943
944    // ─── Edge case tests for normalize_path ──────────────────────────────
945
946    #[test]
947    fn test_normalize_path_workspace_root_with_trailing_slash() {
948        // When workspace_root has a trailing slash, normalization must
949        // still work correctly for both the double-prefix case and
950        // normal relative paths.
951        let ws = PathBuf::from("/home/user/ws");
952        let rel = "src/main.rs";
953        let result = normalize_path(&ws, rel);
954        assert_eq!(result, PathBuf::from("/home/user/ws/src/main.rs"));
955    }
956
957    #[test]
958    fn test_normalize_path_empty_relative() {
959        // An empty relative path should join cleanly to the workspace root
960        let ws = PathBuf::from("/home/user/ws");
961        let result = normalize_path(&ws, "");
962        assert_eq!(result, PathBuf::from("/home/user/ws"));
963    }
964
965    #[test]
966    fn test_normalize_path_triple_prefix_not_collapsed() {
967        // Current behavior: triple-prefix paths find the FIRST reoccurrence
968        // only. This pins the current behavior to catch regressions in the
969        // double-prefix detection algorithm.
970        let ws = PathBuf::from("/ws");
971        let triple = "/ws/ws/ws/foo.rs";
972        let result = normalize_path(&ws, triple);
973        // After stripping first /ws, remainder is "/ws/ws/foo.rs"; first
974        // occurrence of "/ws" in remainder is at index 0 → returns "/ws/ws/foo.rs"
975        assert_eq!(
976            result,
977            PathBuf::from("/ws/ws/foo.rs"),
978            "triple prefix collapses to double prefix — documented behavior"
979        );
980    }
981}