1use super::Tool;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::{Path, PathBuf};
7
8pub 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 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 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 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 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
67pub 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 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
103pub 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 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
243pub 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 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 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 tokio::fs::write(&full_path, content)
341 .await
342 .map_err(crate::PawanError::Io)?;
343
344 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
363pub 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 #[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 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 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 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 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 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 #[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 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 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 #[test]
759 fn test_validate_file_write_blocks_dotgit_writes() {
760 let cases = [
762 "/home/user/repo/.git/HEAD",
763 "/opt/pawan/.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 let allowed = [
827 "/home/user/ws/src/main.rs",
828 "/tmp/scratch/notes.md",
829 "/opt/pawan/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 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 #[test]
872 fn test_validate_file_write_allows_gitignore_not_blocked_as_dotgit() {
873 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 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 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 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 let allowed = ["/home/user/etc/config.toml", "/opt/pawan/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 #[test]
947 fn test_normalize_path_workspace_root_with_trailing_slash() {
948 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 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 let ws = PathBuf::from("/ws");
971 let triple = "/ws/ws/ws/foo.rs";
972 let result = normalize_path(&ws, triple);
973 assert_eq!(
976 result,
977 PathBuf::from("/ws/ws/foo.rs"),
978 "triple prefix collapses to double prefix — documented behavior"
979 );
980 }
981}