Skip to main content

conflic/
lib.rs

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
34/// Run the full conflic scan pipeline on a directory.
35pub 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
40/// Run the scan pipeline while substituting in-memory content for selected files.
41pub 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
50/// Run a diff-scoped scan: only files in `changed_files` plus their concept peers.
51pub 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
60/// Get changed files from git diff against a ref.
61pub 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
74/// Run the scan pipeline in diagnostic mode, collecting intermediate data.
75pub 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}