1use rskit_errors::{AppError, AppResult};
4
5use crate::options::{DescribeOptions, GrepOptions};
6use crate::paths::validate_repo_relative_path;
7use crate::read::{IgnoreReader, Inspector};
8use crate::types::{GrepMatch, Oid};
9
10use super::{GitCli, parse_oid};
11
12impl IgnoreReader for GitCli {
13 fn is_ignored(&self, path: &str) -> AppResult<bool> {
14 validate_repo_relative_path(path)?;
15 let args = ["check-ignore", "--quiet", "--", path];
16 let output = self.run_result(&args)?;
17 match output.exit_code {
18 Some(0) => Ok(true),
19 Some(1) => Ok(false),
20 _ => Err(GitCli::command_failed(&args, output)),
21 }
22 }
23}
24
25impl Inspector for GitCli {
26 fn describe(&self, opts: Option<&DescribeOptions>) -> AppResult<String> {
27 let opts = opts.cloned().unwrap_or_default();
28 let mut args = vec!["describe".to_string()];
29 if !opts.annotated_tags_only {
30 args.push("--tags".to_string());
31 }
32 if opts.long {
33 args.push("--long".to_string());
34 }
35 args.extend(opts.extra_args);
36
37 let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
38 let output = self.run(&refs)?;
39 Ok(String::from_utf8_lossy(&output).trim().to_string())
40 }
41
42 fn rev_parse(&self, revision: &str) -> AppResult<Oid> {
43 let output = self.run(&["rev-parse", revision])?;
44 parse_oid(String::from_utf8_lossy(&output).trim())
45 }
46
47 fn grep(
48 &self,
49 pattern: &str,
50 revision: &str,
51 opts: Option<&GrepOptions>,
52 ) -> AppResult<Vec<GrepMatch>> {
53 let opts = opts.cloned().unwrap_or_default();
54 let mut args = vec!["grep".to_string()];
55 if opts.ignore_case {
56 args.push("-i".to_string());
57 }
58 if opts.line_numbers {
59 args.push("-n".to_string());
60 }
61 args.extend(opts.extra_args);
62 args.push("-e".to_string());
63 args.push(pattern.to_string());
64 args.push(revision.to_string());
65 if !opts.pathspecs.is_empty() {
66 args.push("--".to_string());
67 args.extend(opts.pathspecs);
68 }
69
70 let refs = args.iter().map(String::as_str).collect::<Vec<_>>();
71 let output = self.run(&refs)?;
72 let line_numbers = opts.line_numbers;
73 String::from_utf8_lossy(&output)
74 .lines()
75 .filter(|line| !line.trim().is_empty())
76 .map(|line| parse_grep_match(line, revision, line_numbers))
77 .collect()
78 }
79
80 fn show(&self, object: &str) -> AppResult<Vec<u8>> {
81 self.run(&["show", object])
82 }
83}
84
85fn parse_grep_match(line: &str, revision: &str, line_numbers: bool) -> AppResult<GrepMatch> {
86 let stripped = line.strip_prefix(&format!("{revision}:")).unwrap_or(line);
88
89 let path;
90 let line_number;
91 let content;
92
93 if line_numbers {
94 let invalid_match = || AppError::invalid_format("grep match", "<path>:<line>:<content>");
95 let mut parts = stripped.splitn(3, ':');
96 path = parts.next().ok_or_else(invalid_match)?.to_string();
97 line_number = Some(
98 parts
99 .next()
100 .ok_or_else(invalid_match)?
101 .parse::<usize>()
102 .map_err(|_| invalid_match())?,
103 );
104 content = parts.next().ok_or_else(invalid_match)?.to_string();
105 } else {
106 let invalid_match = || AppError::invalid_format("grep match", "<path>:<content>");
109 let mut parts = stripped.splitn(2, ':');
110 path = parts.next().ok_or_else(invalid_match)?.to_string();
111 content = parts.next().ok_or_else(invalid_match)?.to_string();
112 line_number = None;
113 }
114
115 Ok(GrepMatch {
116 path,
117 line_number,
118 line: content,
119 })
120}
121
122#[cfg(test)]
123mod tests {
124 use std::fs;
125
126 use rskit_errors::ErrorCode;
127
128 use crate::read::IgnoreReader;
129
130 use super::{GitCli, parse_grep_match};
131
132 #[test]
133 fn ignore_reader_reports_gitignored_paths() {
134 let root = rskit_fs::TempDir::new().expect("temp dir");
135 crate::init(root.path()).expect("init repo");
136 fs::write(root.path().join(".gitignore"), "target/\n").expect("write ignore");
137 let backend = GitCli::new(root.path().to_path_buf());
138
139 assert!(
140 backend
141 .is_ignored("target/debug/app")
142 .expect("check ignored path")
143 );
144 assert!(
145 !backend
146 .is_ignored("src/lib.rs")
147 .expect("check visible path")
148 );
149 }
150
151 #[test]
152 fn ignore_reader_rejects_invalid_repository_paths() {
153 let root = rskit_fs::TempDir::new().expect("temp dir");
154 crate::init(root.path()).expect("init repo");
155 let backend = GitCli::new(root.path().to_path_buf());
156
157 let err = backend
158 .is_ignored("../target/debug/app")
159 .expect_err("reject parent traversal");
160
161 assert_eq!(err.code(), ErrorCode::InvalidInput);
162 assert!(err.message().contains("../target/debug/app"));
163 assert_eq!(
164 err.cause()
165 .expect("preserve path validation cause")
166 .to_string(),
167 "path must not contain '..' segments"
168 );
169 }
170
171 #[test]
172 fn parse_grep_match_reports_invalid_format() {
173 let err = parse_grep_match("README.md:not-a-line", "HEAD", true).unwrap_err();
174 assert_eq!(err.code(), ErrorCode::InvalidFormat);
175 assert_eq!(
176 err.message(),
177 "invalid format for grep match: expected <path>:<line>:<content>"
178 );
179 }
180
181 #[test]
182 fn parse_grep_match_with_line_numbers_preserves_colons_in_content() {
183 let matched =
184 parse_grep_match("README.md:12:key: value: still content", "HEAD", true).unwrap();
185
186 assert_eq!(matched.path, "README.md");
187 assert_eq!(matched.line_number, Some(12));
188 assert_eq!(matched.line, "key: value: still content");
189 }
190}