Skip to main content

rskit_git/cli/
read.rs

1//! Read operations for the Git CLI runner.
2
3use 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    // Strip optional revision prefix (e.g., "HEAD:" from "HEAD:path:1:content")
87    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        // Without -n the format is <path>:<content>.
107        // Split from left: path has no colons (relative), content may.
108        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}