provable_contracts/lint/
diff.rs1use std::path::Path;
10use std::process::Command;
11
12pub fn changed_contracts(contracts_dir: &Path, base_ref: &str) -> Result<Vec<String>, String> {
14 let repo_root = find_repo_root(contracts_dir)?;
15
16 let output = Command::new("git")
17 .args([
18 "diff",
19 "--name-only",
20 &format!("{base_ref}..HEAD"),
21 "--",
22 "contracts/",
23 ])
24 .current_dir(repo_root)
25 .output()
26 .map_err(|e| format!("Failed to run git diff: {e}"))?;
27
28 if !output.status.success() {
29 let stderr = String::from_utf8_lossy(&output.stderr);
30 return Err(format!("git diff failed: {stderr}"));
31 }
32
33 let stdout = String::from_utf8_lossy(&output.stdout);
34 let stems: Vec<String> = stdout
35 .lines()
36 .filter(|line| {
37 Path::new(line)
38 .extension()
39 .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
40 })
41 .filter_map(|line| {
42 Path::new(line)
43 .file_stem()
44 .and_then(|s| s.to_str())
45 .map(ToString::to_string)
46 })
47 .collect();
48
49 Ok(stems)
50}
51
52pub fn expand_dependents(
57 changed: &[String],
58 all_contracts: &[(String, crate::schema::Contract)],
59) -> Vec<String> {
60 let mut expanded: std::collections::HashSet<String> = changed.iter().cloned().collect();
61
62 for (stem, contract) in all_contracts {
64 if expanded.contains(stem) {
65 continue;
66 }
67 for dep in &contract.metadata.depends_on {
68 if expanded.contains(dep) {
69 expanded.insert(stem.clone());
70 break;
71 }
72 }
73 }
74
75 let mut result: Vec<String> = expanded.into_iter().collect();
76 result.sort();
77 result
78}
79
80fn find_repo_root(start: &Path) -> Result<String, String> {
81 let output = Command::new("git")
82 .args(["rev-parse", "--show-toplevel"])
83 .current_dir(start)
84 .output()
85 .map_err(|e| format!("Failed to find git repo root: {e}"))?;
86
87 if !output.status.success() {
88 return Err("Not a git repository".into());
89 }
90
91 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 fn contracts_dir() -> std::path::PathBuf {
99 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts")
100 }
101
102 #[test]
103 #[ignore] fn changed_contracts_from_head() {
105 let result = changed_contracts(&contracts_dir(), "HEAD");
107 assert!(result.is_ok());
108 }
109
110 #[test]
111 fn changed_contracts_invalid_ref() {
112 let result = changed_contracts(&contracts_dir(), "nonexistent-ref-xyz");
113 assert!(result.is_err());
114 }
115
116 #[test]
117 fn expand_dependents_no_deps() {
118 let changed = vec!["softmax-kernel-v1".to_string()];
119 let contracts: Vec<(String, crate::schema::Contract)> = vec![];
120 let expanded = expand_dependents(&changed, &contracts);
121 assert_eq!(expanded, vec!["softmax-kernel-v1".to_string()]);
122 }
123
124 #[test]
125 fn expand_dependents_with_real_contracts() {
126 use crate::lint::gates::load_contracts;
128 let dir = contracts_dir();
129 let (all, _) = load_contracts(&dir);
130
131 let changed = vec!["softmax-kernel-v1".to_string()];
133 let expanded = expand_dependents(&changed, &all);
134 assert!(expanded.contains(&"softmax-kernel-v1".to_string()));
135 assert!(!expanded.is_empty());
137 }
138
139 #[test]
140 fn expand_dependents_empty_changed() {
141 let changed: Vec<String> = vec![];
142 let contracts: Vec<(String, crate::schema::Contract)> = vec![];
143 let expanded = expand_dependents(&changed, &contracts);
144 assert!(expanded.is_empty());
145 }
146
147 #[test]
148 #[ignore] fn changed_contracts_with_range() {
150 let result = changed_contracts(&contracts_dir(), "HEAD~50");
153 match result {
154 Err(e) if e.contains("git diff failed") => {
155 }
157 Err(e) => panic!("Unexpected error: {e}"),
158 Ok(stems) => {
159 for stem in &stems {
161 assert!(
162 !stem.to_ascii_lowercase().ends_with(".yaml"),
163 "Stem should not have extension: {stem}"
164 );
165 assert!(
166 !stem.contains('/'),
167 "Stem should not have path separator: {stem}"
168 );
169 }
170 }
171 }
172 }
173
174 #[test]
175 #[ignore] fn find_repo_root_works() {
177 let root = find_repo_root(&contracts_dir());
178 assert!(root.is_ok());
179 assert!(root.unwrap().contains("provable-contracts"));
180 }
181
182 #[test]
183 fn find_repo_root_non_git_dir() {
184 let tmp = tempfile::tempdir().unwrap();
185 let result = find_repo_root(tmp.path());
186 assert!(result.is_err());
187 }
188
189 #[test]
190 fn expand_dependents_transitive() {
191 let a_yaml = r#"
193metadata:
194 version: "1.0.0"
195 description: "A"
196 references: ["Paper"]
197equations:
198 f:
199 formula: "f(x) = x"
200"#;
201 let b_yaml = r#"
202metadata:
203 version: "1.0.0"
204 description: "B"
205 references: ["Paper"]
206 depends_on:
207 - "contract-a"
208equations:
209 g:
210 formula: "g(x) = 2x"
211"#;
212 let c_yaml = r#"
213metadata:
214 version: "1.0.0"
215 description: "C"
216 references: ["Paper"]
217equations:
218 h:
219 formula: "h(x) = 3x"
220"#;
221 let a: crate::schema::Contract = crate::schema::parse_contract_str(a_yaml).unwrap();
222 let b: crate::schema::Contract = crate::schema::parse_contract_str(b_yaml).unwrap();
223 let c: crate::schema::Contract = crate::schema::parse_contract_str(c_yaml).unwrap();
224
225 let all = vec![
226 ("contract-a".to_string(), a),
227 ("contract-b".to_string(), b),
228 ("contract-c".to_string(), c),
229 ];
230
231 let changed = vec!["contract-a".to_string()];
233 let expanded = expand_dependents(&changed, &all);
234 assert!(expanded.contains(&"contract-a".to_string()));
235 assert!(expanded.contains(&"contract-b".to_string()));
236 assert!(!expanded.contains(&"contract-c".to_string()));
238 }
239
240 #[test]
241 fn expand_dependents_already_in_changed() {
242 let yaml = r#"
244metadata:
245 version: "1.0.0"
246 description: "X"
247 references: ["Paper"]
248 depends_on:
249 - "other"
250equations:
251 f:
252 formula: "f(x) = x"
253"#;
254 let contract: crate::schema::Contract = crate::schema::parse_contract_str(yaml).unwrap();
255 let all = vec![("dep-contract".to_string(), contract)];
256 let changed = vec!["dep-contract".to_string(), "other".to_string()];
257 let expanded = expand_dependents(&changed, &all);
258 assert_eq!(expanded.iter().filter(|s| *s == "dep-contract").count(), 1);
260 }
261}