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", ".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 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 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
51pub 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 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
87pub 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 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
210pub 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 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 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 tokio::fs::write(&full_path, content)
294 .await
295 .map_err(crate::PawanError::Io)?;
296
297 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
316pub 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 #[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 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 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 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 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 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 #[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 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 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 #[test]
689 fn test_validate_file_write_blocks_dotgit_writes() {
690 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 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 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 #[test]
810 fn test_validate_file_write_allows_gitignore_not_blocked_as_dotgit() {
811 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 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 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 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 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 #[test]
888 fn test_normalize_path_workspace_root_with_trailing_slash() {
889 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 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 let ws = PathBuf::from("/ws");
912 let triple = "/ws/ws/ws/foo.rs";
913 let result = normalize_path(&ws, triple);
914 assert_eq!(
917 result,
918 PathBuf::from("/ws/ws/foo.rs"),
919 "triple prefix collapses to double prefix — documented behavior"
920 );
921 }
922}