1pub mod baseline;
2pub mod cli;
3pub mod config;
4pub mod discover;
5pub mod error;
6pub mod extract;
7pub mod federation;
8pub mod fix;
9pub mod graph;
10pub mod history;
11#[cfg(feature = "lsp")]
12pub mod lsp;
13pub mod model;
14pub mod parse;
15mod pathing;
16mod pipeline;
17mod planning;
18pub mod policy;
19pub mod report;
20pub mod solve;
21mod workspace;
22
23use std::collections::BTreeSet;
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26
27use config::ConflicConfig;
28use error::{ConflicError, GitError, Result};
29use model::ScanResult;
30
31pub use pipeline::{DoctorFileInfo, DoctorReport};
32pub use workspace::{IncrementalScanKind, IncrementalScanStats, IncrementalWorkspace};
33
34pub fn scan(root: &Path, config: &ConflicConfig) -> Result<ScanResult> {
36 let pipeline = pipeline::run_scan_pipeline(root, config, None);
37 Ok(pipeline.full_scan_result(root, config))
38}
39
40pub fn scan_with_overrides(
42 root: &Path,
43 config: &ConflicConfig,
44 content_overrides: &HashMap<PathBuf, String>,
45) -> Result<ScanResult> {
46 let pipeline = pipeline::run_scan_pipeline(root, config, Some(content_overrides));
47 Ok(pipeline.full_scan_result(root, config))
48}
49
50pub fn scan_diff(
52 root: &Path,
53 config: &ConflicConfig,
54 changed_files: &[PathBuf],
55) -> Result<ScanResult> {
56 let pipeline = pipeline::run_diff_scan_pipeline(root, config, changed_files);
57 Ok(pipeline.full_scan_result(root, config))
58}
59
60pub fn git_changed_files(root: &Path, git_ref: &str) -> Result<Vec<PathBuf>> {
62 validate_git_diff_ref(git_ref)?;
63
64 let diff_args = ["diff", "--name-only", "-z", git_ref, "--"];
65 let untracked_args = ["ls-files", "--others", "--exclude-standard", "-z"];
66
67 let mut files = BTreeSet::new();
68 files.extend(git_command_path_lines(root, &diff_args)?);
69 files.extend(git_command_path_lines(root, &untracked_args)?);
70
71 Ok(files.into_iter().collect())
72}
73
74pub fn scan_doctor(root: &Path, config: &ConflicConfig) -> Result<DoctorReport> {
76 let pipeline = pipeline::run_scan_pipeline(root, config, None);
77 Ok(pipeline.into_doctor_report(root, config))
78}
79
80fn git_command_path_lines(root: &Path, args: &[&str]) -> Result<Vec<PathBuf>> {
81 let command = format_command(args);
82 let output = std::process::Command::new("git")
83 .args(args)
84 .current_dir(root)
85 .output()
86 .map_err(|source| ConflicError::from(GitError::Spawn { command, source }))?;
87
88 if !output.status.success() {
89 let stderr = String::from_utf8_lossy(&output.stderr);
90 return Err(ConflicError::from(GitError::CommandFailed {
91 command: format_command(args),
92 stderr: stderr.trim().to_string(),
93 }));
94 }
95
96 Ok(parse_git_path_output(&output.stdout, args.contains(&"-z")))
97}
98
99fn format_command(args: &[&str]) -> String {
100 args.join(" ")
101}
102
103fn validate_git_diff_ref(git_ref: &str) -> Result<()> {
104 if git_ref.starts_with('-') {
105 return Err(ConflicError::from(GitError::InvalidDiffRef {
106 value: git_ref.to_string(),
107 }));
108 }
109
110 Ok(())
111}
112
113fn parse_git_path_output(stdout: &[u8], nul_terminated: bool) -> Vec<PathBuf> {
114 let parts: Vec<&[u8]> = if nul_terminated {
115 stdout.split(|byte| *byte == b'\0').collect()
116 } else {
117 stdout.split(|byte| *byte == b'\n').collect()
118 };
119
120 parts
121 .into_iter()
122 .filter(|part| !part.is_empty())
123 .map(String::from_utf8_lossy)
124 .map(|path| {
125 let path = if nul_terminated {
126 path.as_ref()
127 } else {
128 path.trim_end_matches('\r')
129 };
130 PathBuf::from(path)
131 })
132 .collect()
133}
134
135#[cfg(test)]
136#[path = "tests/mod.rs"]
137mod hardening_tests;
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use std::process::Command;
143
144 fn run_git(repo: &Path, args: &[&str]) {
145 let output = Command::new("git")
146 .args(args)
147 .current_dir(repo)
148 .output()
149 .unwrap();
150
151 assert!(
152 output.status.success(),
153 "git {:?} failed\nstdout: {}\nstderr: {}",
154 args,
155 String::from_utf8_lossy(&output.stdout),
156 String::from_utf8_lossy(&output.stderr)
157 );
158 }
159
160 #[test]
161 fn test_git_changed_files_rejects_option_like_ref() {
162 let dir = tempfile::tempdir().unwrap();
163 let root = dir.path();
164
165 run_git(root, &["init"]);
166 run_git(root, &["config", "user.email", "codex@example.com"]);
167 run_git(root, &["config", "user.name", "Codex"]);
168 std::fs::write(root.join(".nvmrc"), "20\n").unwrap();
169 run_git(root, &["add", "."]);
170 run_git(root, &["commit", "-m", "initial"]);
171
172 let err = git_changed_files(root, "--output=owned.txt").unwrap_err();
173 let owned_path = root.join("owned.txt");
174
175 assert!(
176 err.to_string().contains("must not start with '-'"),
177 "unexpected error message: {err}"
178 );
179 assert!(
180 !owned_path.exists(),
181 "option-like refs must not be forwarded to git"
182 );
183 }
184
185 #[test]
186 fn test_git_changed_files_handles_unicode_paths() {
187 let dir = tempfile::tempdir().unwrap();
188 let root = dir.path();
189 let nested = root.join("unicode-é");
190 let package = nested.join("package.json");
191
192 run_git(root, &["init"]);
193 run_git(root, &["config", "user.email", "codex@example.com"]);
194 run_git(root, &["config", "user.name", "Codex"]);
195 std::fs::create_dir_all(&nested).unwrap();
196 std::fs::write(&package, r#"{"engines":{"node":"20"}}"#).unwrap();
197 run_git(root, &["add", "."]);
198 run_git(root, &["commit", "-m", "initial"]);
199
200 std::fs::write(&package, r#"{"engines":{"node":"18"}}"#).unwrap();
201
202 let changed = git_changed_files(root, "HEAD").unwrap();
203
204 assert!(
205 changed
206 .iter()
207 .any(|path| path == Path::new("unicode-é/package.json")),
208 "unicode paths should round-trip through git diff output: {:?}",
209 changed
210 );
211 }
212
213 #[test]
214 fn test_parse_git_path_output_supports_nul_terminated_entries() {
215 let output = b"package.json\0unicode-\xC3\xA9/.nvmrc\0";
216 let parsed = parse_git_path_output(output, true);
217
218 assert_eq!(
219 parsed,
220 vec![
221 PathBuf::from("package.json"),
222 PathBuf::from("unicode-é/.nvmrc"),
223 ]
224 );
225 }
226
227 #[test]
228 fn test_parse_git_path_output_preserves_significant_whitespace() {
229 let output = b" pkg/settings.json\0trailing-space /.env\0";
230 let parsed = parse_git_path_output(output, true);
231
232 assert_eq!(
233 parsed,
234 vec![
235 PathBuf::from(" pkg/settings.json"),
236 PathBuf::from("trailing-space /.env"),
237 ]
238 );
239 }
240}