1use std::collections::HashMap;
7use std::fmt::Write as _;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12const PRODUCT_NAMES: &[&str] = &["contoso", "fabrikam", "northwind"];
14
15const FORBIDDEN_PERMISSIONS: &[&str] = &[
17 "fs:allow-all",
18 "fs:default",
19 "shell:allow-execute",
20 "shell:allow-spawn",
21 "shell:default",
22];
23
24pub fn run(root: &Path) -> Result<(), String> {
25 let root = root.to_path_buf();
26 let mut failures = Vec::new();
27
28 failures.extend(check_layer_dependencies(&root)?);
29 failures.extend(check_no_product_names(&root)?);
30 failures.extend(check_invoke_is_confined(&root)?);
31 failures.extend(check_capabilities(&root)?);
32 failures.extend(check_commands_exist(&root)?);
33 failures.extend(check_manifest_matches_tauri_config(&root)?);
34
35 if failures.is_empty() {
36 println!("architecture rules: ok");
37 return Ok(());
38 }
39
40 let mut report = String::from("architecture violations:\n");
41 for failure in &failures {
42 let _ = writeln!(report, " - {failure}");
43 }
44 Err(report)
45}
46
47fn check_layer_dependencies(root: &Path) -> Result<Vec<String>, String> {
49 let mut failures = Vec::new();
50 let dependencies_by_package = workspace_dependencies(root)?;
51
52 for (layer, forbidden) in [
53 ("crates", &["tauri", "adapters", "host", "examples"][..]),
54 ("adapters", &["adapters", "examples"][..]),
55 ("host", &["examples"][..]),
56 ] {
57 for manifest in crates_in(&root.join(layer))? {
58 let package = manifest
59 .parent()
60 .and_then(|path| path.file_name())
61 .map(|name| name.to_string_lossy().into_owned())
62 .unwrap_or_default();
63 let dependencies = dependencies_by_package
64 .get(&package)
65 .cloned()
66 .unwrap_or_default();
67
68 for dependency in &dependencies {
69 if layer == "adapters" && dependency == &package {
72 continue;
73 }
74
75 let violates = match layer {
76 "crates" => {
77 dependency.starts_with("tauri")
78 || is_workspace_member_of(root, dependency, forbidden)
79 }
80 _ => is_workspace_member_of(root, dependency, forbidden),
81 };
82
83 if violates {
84 failures.push(format!(
85 "{layer}/{package} depends on `{dependency}` — {layer} must not \
86 depend on {}",
87 forbidden.join(", ")
88 ));
89 }
90 }
91 }
92 }
93
94 Ok(failures)
95}
96
97fn check_no_product_names(root: &Path) -> Result<Vec<String>, String> {
102 let mut failures = Vec::new();
103 let tooling = root.join("crates").join("origin-xtask");
104
105 for file in rust_sources(&root.join("crates"))? {
106 if file.starts_with(&tooling) {
107 continue;
108 }
109
110 let contents = read(&file)?.to_lowercase();
111 for product in PRODUCT_NAMES {
112 if contents.contains(product) {
115 failures.push(format!(
116 "{} mentions the product `{product}` — platform code must not know \
117 its consumers",
118 relative(root, &file)
119 ));
120 }
121 }
122 }
123
124 Ok(failures)
125}
126
127fn check_invoke_is_confined(root: &Path) -> Result<Vec<String>, String> {
133 let mut failures = Vec::new();
134 let allowed = root.join("frontend").join("client");
137
138 for file in frontend_sources(root)? {
139 if file.starts_with(&allowed) {
140 continue;
141 }
142
143 let contents = read(&file)?;
144 if contents.contains("@tauri-apps/api") {
145 failures.push(format!(
146 "{} imports `@tauri-apps/api` — go through `@origin/client` instead \
147 (ADR-0010)",
148 relative(root, &file)
149 ));
150 }
151 }
152
153 Ok(failures)
154}
155
156fn check_capabilities(root: &Path) -> Result<Vec<String>, String> {
158 let mut failures = Vec::new();
159
160 for file in files_with_extension(root, "json")? {
161 if !file
162 .parent()
163 .is_some_and(|parent| parent.ends_with("capabilities"))
164 {
165 continue;
166 }
167
168 let contents = read(&file)?;
169 for permission in FORBIDDEN_PERMISSIONS {
170 if contents.contains(permission) {
171 failures.push(format!(
172 "{} grants `{permission}` — that is not least privilege (ADR-0007)",
173 relative(root, &file)
174 ));
175 }
176 }
177 }
178
179 Ok(failures)
180}
181
182fn check_manifest_matches_tauri_config(root: &Path) -> Result<Vec<String>, String> {
188 let mut failures = Vec::new();
189
190 for manifest_path in crate::find_manifests(root)? {
191 let project = manifest_path.parent().unwrap_or(root);
192 let config_path = project.join("src-tauri").join("tauri.conf.json");
193
194 let config = match read(&config_path) {
195 Ok(config) => config,
196 Err(error) => {
197 failures.push(error);
198 continue;
199 }
200 };
201 let config: serde_json::Value = match serde_json::from_str(&config) {
202 Ok(config) => config,
203 Err(error) => {
204 failures.push(format!("{}: {error}", relative(root, &config_path)));
205 continue;
206 }
207 };
208
209 let manifest = match origin_manifest::Manifest::load(&manifest_path) {
210 Ok(manifest) => manifest,
211 Err(error) => {
212 failures.push(error.to_string());
213 continue;
214 }
215 };
216
217 for (field, expected, actual) in [
218 ("identifier", &manifest.product.id, config.get("identifier")),
219 (
220 "productName",
221 &manifest.product.name,
222 config.get("productName"),
223 ),
224 ("version", &manifest.product.version, config.get("version")),
225 ] {
226 let actual = actual
227 .and_then(serde_json::Value::as_str)
228 .unwrap_or_default();
229 if actual != expected {
230 failures.push(format!(
231 "{}: `{field}` is `{actual}`, but app.toml says `{expected}`",
232 relative(root, &config_path)
233 ));
234 }
235 }
236 }
237
238 Ok(failures)
239}
240
241fn check_commands_exist(root: &Path) -> Result<Vec<String>, String> {
246 let mut defined = Vec::new();
247 for file in rust_sources(root)? {
248 let contents = read(&file)?;
249 defined.extend(tauri_command_names(&contents));
250 }
251
252 let mut failures = Vec::new();
253 for file in frontend_sources(root)? {
254 let contents = read(&file)?;
255 for name in invoked_command_names(&contents) {
256 if !defined.contains(&name) {
257 failures.push(format!(
258 "{} calls the command `{name}`, which no `#[tauri::command]` defines",
259 relative(root, &file)
260 ));
261 }
262 }
263 }
264
265 Ok(failures)
266}
267
268fn tauri_command_names(contents: &str) -> Vec<String> {
270 let mut names = Vec::new();
271 let mut annotated = false;
272
273 for line in contents.lines() {
274 let line = line.trim();
275
276 if line.starts_with("#[tauri::command") {
277 annotated = true;
278 continue;
279 }
280
281 if annotated {
282 if line.starts_with("#[") {
283 continue;
284 }
285 if let Some(name) = line
286 .split("fn ")
287 .nth(1)
288 .and_then(|rest| rest.split(['(', '<', ' ']).next())
289 {
290 names.push(name.to_owned());
291 }
292 annotated = false;
293 }
294 }
295
296 names
297}
298
299fn invoked_command_names(contents: &str) -> Vec<String> {
301 let mut names = Vec::new();
302 let mut remaining = contents;
303
304 while let Some(index) = remaining.find("command") {
305 remaining = &remaining[index + "command".len()..];
306 let Some(arguments) = remaining.find('(') else {
307 break;
308 };
309 let before_arguments = &remaining[..arguments];
310 let plain_call = before_arguments.trim().is_empty();
311 let generic_call = before_arguments.trim_start().starts_with('<')
312 && before_arguments.trim_end().ends_with('>');
313 if !(plain_call || generic_call) {
314 continue;
315 }
316
317 let rest = &remaining[arguments + 1..];
318 let rest = rest.trim_start();
319 let Some(quote) = rest
320 .chars()
321 .next()
322 .filter(|quote| matches!(quote, '"' | '\''))
323 else {
324 continue;
325 };
326 let value = &rest[quote.len_utf8()..];
327 let Some(close) = value.find(quote) else {
328 continue;
329 };
330 names.push(value[..close].to_owned());
331 }
332
333 names
334}
335
336fn is_workspace_member_of(root: &Path, name: &str, layers: &[&str]) -> bool {
342 layers.iter().any(|layer| {
343 let direct = root.join(layer).join(name).join("Cargo.toml").exists();
344 let nested =
346 root.join(layer).join("demo").join("src-tauri").exists() && name == "origin-demo";
347 direct || (*layer == "examples" && nested)
348 })
349}
350
351fn crates_in(directory: &Path) -> Result<Vec<PathBuf>, String> {
352 if !directory.exists() {
353 return Ok(Vec::new());
354 }
355
356 let mut manifests = Vec::new();
357 for entry in read_dir(directory)? {
358 let manifest = entry.join("Cargo.toml");
359 if manifest.exists() {
360 manifests.push(manifest);
361 }
362 }
363 Ok(manifests)
364}
365
366fn workspace_dependencies(root: &Path) -> Result<HashMap<String, Vec<String>>, String> {
375 let output = Command::new("cargo")
376 .args(["metadata", "--no-deps", "--format-version", "1"])
377 .current_dir(root)
378 .output()
379 .map_err(|error| format!("cannot run cargo metadata: {error}"))?;
380
381 if !output.status.success() {
382 return Err(format!(
383 "cargo metadata failed: {}",
384 String::from_utf8_lossy(&output.stderr)
385 ));
386 }
387
388 let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
389 .map_err(|error| format!("cannot parse cargo metadata output: {error}"))?;
390
391 dependencies_from_metadata(&metadata)
392}
393
394fn dependencies_from_metadata(
397 metadata: &serde_json::Value,
398) -> Result<HashMap<String, Vec<String>>, String> {
399 let packages = metadata
400 .get("packages")
401 .and_then(serde_json::Value::as_array)
402 .ok_or_else(|| "cargo metadata: no `packages` array in its output".to_owned())?;
403
404 let mut by_package = HashMap::new();
405 for package in packages {
406 let Some(name) = package.get("name").and_then(serde_json::Value::as_str) else {
407 continue;
408 };
409
410 let dependencies = package
415 .get("dependencies")
416 .and_then(serde_json::Value::as_array)
417 .map(|dependencies| {
418 dependencies
419 .iter()
420 .filter_map(|dependency| {
421 dependency
422 .get("name")
423 .and_then(serde_json::Value::as_str)
424 .map(str::to_owned)
425 })
426 .collect()
427 })
428 .unwrap_or_default();
429
430 by_package.insert(name.to_owned(), dependencies);
431 }
432
433 Ok(by_package)
434}
435
436fn rust_sources(directory: &Path) -> Result<Vec<PathBuf>, String> {
437 files_with_extension(directory, "rs")
438}
439
440fn frontend_sources(directory: &Path) -> Result<Vec<PathBuf>, String> {
441 let mut files = files_with_extension(directory, "ts")?;
442 files.extend(files_with_extension(directory, "svelte")?);
443 Ok(files)
444}
445
446fn files_with_extension(directory: &Path, extension: &str) -> Result<Vec<PathBuf>, String> {
447 if !directory.exists() {
448 return Ok(Vec::new());
449 }
450
451 let mut files = Vec::new();
452 for entry in read_dir(directory)? {
453 let name = entry
454 .file_name()
455 .unwrap_or_default()
456 .to_string_lossy()
457 .into_owned();
458 if entry.is_dir() {
459 if matches!(
461 name.as_str(),
462 "node_modules" | "target" | "dist" | "gen" | ".git"
463 ) {
464 continue;
465 }
466 files.extend(files_with_extension(&entry, extension)?);
467 } else if entry.extension().is_some_and(|found| found == extension) {
468 files.push(entry);
469 }
470 }
471 Ok(files)
472}
473
474fn read_dir(directory: &Path) -> Result<Vec<PathBuf>, String> {
475 let mut entries: Vec<PathBuf> = fs::read_dir(directory)
476 .map_err(|error| format!("cannot read {}: {error}", directory.display()))?
477 .filter_map(Result::ok)
478 .map(|entry| entry.path())
479 .collect();
480 entries.sort();
481 Ok(entries)
482}
483
484fn read(file: &Path) -> Result<String, String> {
485 fs::read_to_string(file).map_err(|error| format!("cannot read {}: {error}", file.display()))
486}
487
488fn relative(root: &Path, file: &Path) -> String {
489 file.strip_prefix(root)
490 .unwrap_or(file)
491 .to_string_lossy()
492 .into_owned()
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[test]
500 fn command_calls_with_and_without_a_generic_are_found() {
501 let source = r#"
502 command<Result>("with_result");
503 command('without_result');
504 "#;
505
506 assert_eq!(
507 invoked_command_names(source),
508 vec!["with_result", "without_result"]
509 );
510 }
511
512 #[test]
516 fn dev_target_and_renamed_dependencies_are_all_reported_by_their_real_name() {
517 let metadata = serde_json::json!({
518 "packages": [
519 {
520 "name": "origin-example",
521 "dependencies": [
522 { "name": "origin-core", "rename": null, "kind": null, "target": null },
523 { "name": "tauri", "rename": "desktop", "kind": null, "target": null },
524 { "name": "origin-storage", "rename": null, "kind": "dev", "target": null },
525 {
526 "name": "winapi",
527 "rename": null,
528 "kind": null,
529 "target": "cfg(windows)"
530 }
531 ]
532 }
533 ]
534 });
535
536 let dependencies = dependencies_from_metadata(&metadata).unwrap();
537
538 let mut names = dependencies["origin-example"].clone();
539 names.sort();
540 assert_eq!(
541 names,
542 vec!["origin-core", "origin-storage", "tauri", "winapi"]
543 );
544 }
545
546 #[test]
547 fn a_package_with_no_dependencies_field_is_reported_as_having_none() {
548 let metadata = serde_json::json!({
549 "packages": [{ "name": "origin-leaf" }]
550 });
551
552 let dependencies = dependencies_from_metadata(&metadata).unwrap();
553 assert_eq!(dependencies["origin-leaf"], Vec::<String>::new());
554 }
555}