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