tenseleyflow/bencch / 2691f2b

Browse files

Add project differential campaign

Authored by espadonne
SHA
2691f2b562057cca4cfd7315063c5c1e4bf29397
Parents
88d4bf1
Tree
95ab78b

4 changed files

StatusFile+-
M README.md 14 0
M bench/src/lib.rs 44 2
A bench/src/project_campaign.rs 1191 0
A projects/fortrangoingonforty.afproj 89 0
README.mdmodified
@@ -65,6 +65,18 @@ Run differential checks with explicit reference compiler paths:
6565
 cargo run -p afs-tests -- run --suite differential/runtime-control-flow --gfortran-bin /opt/homebrew/bin/gfortran --flang-bin /opt/homebrew/bin/flang-new
6666
 ```
6767
 
68
+List the staged real-project differential ladder:
69
+
70
+```bash
71
+cargo run -p afs-tests -- projects list
72
+```
73
+
74
+Run one real project through its native build system with `armfortas` and `flang-new`:
75
+
76
+```bash
77
+cargo run -p afs-tests -- projects run --project fortbite --armfortas-bin ./target/release/armfortas
78
+```
79
+
6880
 Run one case with full stage capture:
6981
 
7082
 ```bash
@@ -85,6 +97,8 @@ cargo run -p afs-tests -- run --suite differential
8597
 
8698
 Reports are written under `bencch/reports/`.
8799
 
100
+Project differential reports land under `bencch/reports/projects/`.
101
+
88102
 Environment overrides work too:
89103
 
90104
 ```bash
bench/src/lib.rsmodified
@@ -1,4 +1,5 @@
11
 mod compiler;
2
+mod project_campaign;
23
 
34
 use std::collections::{BTreeMap, BTreeSet, VecDeque};
45
 use std::fs;
@@ -10,6 +11,9 @@ use crate::compiler::{
1011
     capture_from_path, compile_output, CaptureFailure, CaptureRequest, CaptureResult,
1112
     CapturedStage, EmitMode, FailureStage, OptLevel, RunCapture, Stage,
1213
 };
14
+use crate::project_campaign::{
15
+    handle_project_command, parse_project_cli, print_project_usage, ProjectCommand,
16
+};
1317
 
1418
 const SUITE_EXTENSION: &str = "afs";
1519
 
@@ -218,6 +222,7 @@ struct ToolchainConfig {
218222
     armfortas: ArmfortasCliAdapter,
219223
     gfortran: String,
220224
     flang_new: String,
225
+    cc: String,
221226
     system_as: String,
222227
     otool: String,
223228
     nm: String,
@@ -232,6 +237,7 @@ impl ToolchainConfig {
232237
             },
233238
             gfortran: tool_override("BENCCH_GFORTRAN_BIN", "gfortran"),
234239
             flang_new: tool_override("BENCCH_FLANG_BIN", "flang-new"),
240
+            cc: tool_override("BENCCH_CC_BIN", "cc"),
235241
             system_as: tool_override("BENCCH_AS_BIN", "as"),
236242
             otool: tool_override("BENCCH_OTOOL_BIN", "otool"),
237243
             nm: tool_override("BENCCH_NM_BIN", "nm"),
@@ -263,6 +269,10 @@ impl ToolchainConfig {
263269
         &self.system_as
264270
     }
265271
 
272
+    fn cc_bin(&self) -> &str {
273
+        &self.cc
274
+    }
275
+
266276
     fn otool_bin(&self) -> &str {
267277
         &self.otool
268278
     }
@@ -474,6 +484,25 @@ pub fn run_cli(args: &[String]) -> i32 {
474484
                 1
475485
             }
476486
         },
487
+        Ok(CommandKind::Projects(command)) => match handle_project_command(command) {
488
+            Ok(outcome) => {
489
+                for line in &outcome.summary_lines {
490
+                    println!("{}", line);
491
+                }
492
+                for workdir in &outcome.kept_workdirs {
493
+                    println!("kept workdir: {}", workdir.display());
494
+                }
495
+                if outcome.success {
496
+                    0
497
+                } else {
498
+                    1
499
+                }
500
+            }
501
+            Err(err) => {
502
+                eprintln!("afs-tests: {}", err);
503
+                1
504
+            }
505
+        },
477506
         Ok(CommandKind::Help) => {
478507
             print_usage();
479508
             0
@@ -489,6 +518,7 @@ pub fn run_cli(args: &[String]) -> i32 {
489518
 enum CommandKind {
490519
     List { suite_filter: Option<String> },
491520
     Run(Box<RunConfig>),
521
+    Projects(ProjectCommand),
492522
     Help,
493523
 }
494524
 
@@ -559,6 +589,10 @@ fn parse_cli(args: &[String]) -> Result<CommandKind, String> {
559589
                         let value = queue.pop_front().ok_or("--flang-bin requires a value")?;
560590
                         config.tools.flang_new = value.clone();
561591
                     }
592
+                    "--cc-bin" => {
593
+                        let value = queue.pop_front().ok_or("--cc-bin requires a value")?;
594
+                        config.tools.cc = value.clone();
595
+                    }
562596
                     "--as-bin" => {
563597
                         let value = queue.pop_front().ok_or("--as-bin requires a value")?;
564598
                         config.tools.system_as = value.clone();
@@ -577,6 +611,10 @@ fn parse_cli(args: &[String]) -> Result<CommandKind, String> {
577611
             }
578612
             Ok(CommandKind::Run(Box::new(config)))
579613
         }
614
+        "projects" => Ok(CommandKind::Projects(parse_project_cli(
615
+            &args[1..],
616
+            ToolchainConfig::from_env(),
617
+        )?)),
580618
         "--help" | "-h" | "help" => Ok(CommandKind::Help),
581619
         other => Err(format!("unknown command: {}", other)),
582620
     }
@@ -588,12 +626,13 @@ fn print_usage() {
588626
     eprintln!("usage:");
589627
     eprintln!("  cargo run -p afs-tests -- list [--suite <filter>]");
590628
     eprintln!(
591
-        "  cargo run -p afs-tests -- run [--suite <filter>] [--case <filter>] [--opt <O0,O1,...>] [--verbose] [--fail-fast] [--include-future] [--all] [--armfortas-bin <path>] [--gfortran-bin <path>] [--flang-bin <path>] [--as-bin <path>] [--otool-bin <path>] [--nm-bin <path>]"
629
+        "  cargo run -p afs-tests -- run [--suite <filter>] [--case <filter>] [--opt <O0,O1,...>] [--verbose] [--fail-fast] [--include-future] [--all] [--armfortas-bin <path>] [--gfortran-bin <path>] [--flang-bin <path>] [--cc-bin <path>] [--as-bin <path>] [--otool-bin <path>] [--nm-bin <path>]"
592630
     );
631
+    print_project_usage();
593632
     eprintln!();
594633
     eprintln!("env overrides:");
595634
     eprintln!("  BENCCH_ARMFORTAS_BIN, BENCCH_GFORTRAN_BIN, BENCCH_FLANG_BIN");
596
-    eprintln!("  BENCCH_AS_BIN, BENCCH_OTOOL_BIN, BENCCH_NM_BIN");
635
+    eprintln!("  BENCCH_CC_BIN, BENCCH_AS_BIN, BENCCH_OTOOL_BIN, BENCCH_NM_BIN");
597636
 }
598637
 
599638
 fn default_suite_root() -> PathBuf {
@@ -5050,6 +5089,8 @@ end
50505089
             "/tmp/gfortran".to_string(),
50515090
             "--flang-bin".to_string(),
50525091
             "/tmp/flang-new".to_string(),
5092
+            "--cc-bin".to_string(),
5093
+            "/tmp/clang".to_string(),
50535094
             "--as-bin".to_string(),
50545095
             "/tmp/as".to_string(),
50555096
             "--otool-bin".to_string(),
@@ -5074,6 +5115,7 @@ end
50745115
         );
50755116
         assert_eq!(config.tools.gfortran, "/tmp/gfortran");
50765117
         assert_eq!(config.tools.flang_new, "/tmp/flang-new");
5118
+        assert_eq!(config.tools.cc, "/tmp/clang");
50775119
         assert_eq!(config.tools.system_as, "/tmp/as");
50785120
         assert_eq!(config.tools.otool, "/tmp/otool");
50795121
         assert_eq!(config.tools.nm, "/tmp/nm");
bench/src/project_campaign.rsadded
1191 lines changed — click to load
@@ -0,0 +1,1191 @@
1
+use std::fmt::Write as _;
2
+use std::fs;
3
+use std::io;
4
+use std::path::{Path, PathBuf};
5
+use std::process::Command;
6
+use std::time::Instant;
7
+
8
+use crate::{sanitize_component, ArmfortasCliAdapter, ToolchainConfig};
9
+
10
+const CATALOG_EXTENSION: &str = "afproj";
11
+
12
+#[derive(Debug, Clone)]
13
+pub(crate) enum ProjectCommand {
14
+    List {
15
+        catalog_filter: Option<String>,
16
+        include_deprioritized: bool,
17
+    },
18
+    Run(ProjectRunConfig),
19
+}
20
+
21
+#[derive(Debug, Clone)]
22
+pub(crate) struct ProjectRunConfig {
23
+    pub(crate) catalog_filter: Option<String>,
24
+    pub(crate) project_filter: Option<String>,
25
+    pub(crate) keep_workdir: bool,
26
+    pub(crate) tools: ToolchainConfig,
27
+}
28
+
29
+#[derive(Debug, Clone)]
30
+pub(crate) struct ProjectRunOutcome {
31
+    pub(crate) kept_workdirs: Vec<PathBuf>,
32
+    pub(crate) summary_lines: Vec<String>,
33
+    pub(crate) success: bool,
34
+}
35
+
36
+#[derive(Debug, Clone)]
37
+struct ProjectCatalog {
38
+    name: String,
39
+    path: PathBuf,
40
+    projects: Vec<ProjectSpec>,
41
+}
42
+
43
+#[derive(Debug, Clone)]
44
+struct ProjectSpec {
45
+    name: String,
46
+    source: PathBuf,
47
+    native_build: String,
48
+    priority: usize,
49
+    status: ProjectStatus,
50
+    coverage: Vec<String>,
51
+    library_seed: Vec<String>,
52
+    build_command: String,
53
+    test_command: Option<String>,
54
+    smoke_command: Option<String>,
55
+    notes: Vec<String>,
56
+}
57
+
58
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59
+enum ProjectStatus {
60
+    Active,
61
+    Later,
62
+    Deprioritized,
63
+}
64
+
65
+impl ProjectStatus {
66
+    fn parse(raw: &str) -> Result<Self, String> {
67
+        match raw.trim().to_ascii_lowercase().as_str() {
68
+            "active" => Ok(Self::Active),
69
+            "later" => Ok(Self::Later),
70
+            "deprioritized" => Ok(Self::Deprioritized),
71
+            other => Err(format!("unknown project status '{}'", other)),
72
+        }
73
+    }
74
+
75
+    fn as_str(&self) -> &'static str {
76
+        match self {
77
+            Self::Active => "active",
78
+            Self::Later => "later",
79
+            Self::Deprioritized => "deprioritized",
80
+        }
81
+    }
82
+}
83
+
84
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85
+enum ProjectCompiler {
86
+    Armfortas,
87
+    FlangNew,
88
+}
89
+
90
+impl ProjectCompiler {
91
+    fn as_str(&self) -> &'static str {
92
+        match self {
93
+            Self::Armfortas => "armfortas",
94
+            Self::FlangNew => "flang-new",
95
+        }
96
+    }
97
+}
98
+
99
+#[derive(Debug, Clone)]
100
+struct ProjectExecution {
101
+    compiler: ProjectCompiler,
102
+    compiler_bin: String,
103
+    cc_bin: String,
104
+    workdir: PathBuf,
105
+    build: StepExecution,
106
+    test: Option<StepExecution>,
107
+    smoke: Option<StepExecution>,
108
+}
109
+
110
+#[derive(Debug, Clone)]
111
+struct StepExecution {
112
+    label: &'static str,
113
+    command: String,
114
+    duration_ms: u128,
115
+    exit_code: i32,
116
+    stdout: String,
117
+    stderr: String,
118
+}
119
+
120
+impl StepExecution {
121
+    fn succeeded(&self) -> bool {
122
+        self.exit_code == 0
123
+    }
124
+}
125
+
126
+#[derive(Debug, Clone)]
127
+struct DifferentialFinding {
128
+    step: &'static str,
129
+    detail: String,
130
+}
131
+
132
+pub(crate) fn parse_project_cli(
133
+    args: &[String],
134
+    tools: ToolchainConfig,
135
+) -> Result<ProjectCommand, String> {
136
+    if args.is_empty() {
137
+        return Err("projects requires a subcommand (list or run)".into());
138
+    }
139
+
140
+    match args[0].as_str() {
141
+        "list" => {
142
+            let mut catalog_filter = None;
143
+            let mut include_deprioritized = false;
144
+            let mut queue: std::collections::VecDeque<&String> = args[1..].iter().collect();
145
+            while let Some(arg) = queue.pop_front() {
146
+                match arg.as_str() {
147
+                    "--catalog" => {
148
+                        let value = queue.pop_front().ok_or("--catalog requires a value")?;
149
+                        catalog_filter = Some(value.clone());
150
+                    }
151
+                    "--all" => include_deprioritized = true,
152
+                    other => return Err(format!("unknown projects list option: {}", other)),
153
+                }
154
+            }
155
+            Ok(ProjectCommand::List {
156
+                catalog_filter,
157
+                include_deprioritized,
158
+            })
159
+        }
160
+        "run" => {
161
+            let mut config = ProjectRunConfig {
162
+                catalog_filter: None,
163
+                project_filter: None,
164
+                keep_workdir: false,
165
+                tools,
166
+            };
167
+            let mut queue: std::collections::VecDeque<&String> = args[1..].iter().collect();
168
+            while let Some(arg) = queue.pop_front() {
169
+                match arg.as_str() {
170
+                    "--catalog" => {
171
+                        let value = queue.pop_front().ok_or("--catalog requires a value")?;
172
+                        config.catalog_filter = Some(value.clone());
173
+                    }
174
+                    "--project" => {
175
+                        let value = queue.pop_front().ok_or("--project requires a value")?;
176
+                        config.project_filter = Some(value.clone());
177
+                    }
178
+                    "--keep-workdir" => config.keep_workdir = true,
179
+                    "--armfortas-bin" => {
180
+                        let value = queue
181
+                            .pop_front()
182
+                            .ok_or("--armfortas-bin requires a value")?;
183
+                        config.tools.armfortas = ArmfortasCliAdapter::External(value.clone());
184
+                    }
185
+                    "--flang-bin" => {
186
+                        let value = queue.pop_front().ok_or("--flang-bin requires a value")?;
187
+                        config.tools.flang_new = value.clone();
188
+                    }
189
+                    "--cc-bin" => {
190
+                        let value = queue.pop_front().ok_or("--cc-bin requires a value")?;
191
+                        config.tools.cc = value.clone();
192
+                    }
193
+                    other => return Err(format!("unknown projects run option: {}", other)),
194
+                }
195
+            }
196
+            if config.project_filter.is_none() {
197
+                return Err("projects run requires --project <name>".into());
198
+            }
199
+            Ok(ProjectCommand::Run(config))
200
+        }
201
+        other => Err(format!("unknown projects subcommand: {}", other)),
202
+    }
203
+}
204
+
205
+pub(crate) fn print_project_usage() {
206
+    eprintln!("  cargo run -p afs-tests -- projects list [--catalog <filter>] [--all]");
207
+    eprintln!(
208
+        "  cargo run -p afs-tests -- projects run --project <name> [--catalog <filter>] [--keep-workdir] [--armfortas-bin <path>] [--flang-bin <path>] [--cc-bin <path>]"
209
+    );
210
+    eprintln!();
211
+    eprintln!("project env overrides:");
212
+    eprintln!("  BENCCH_ARMFORTAS_BIN, BENCCH_FLANG_BIN, BENCCH_CC_BIN");
213
+}
214
+
215
+pub(crate) fn handle_project_command(command: ProjectCommand) -> Result<ProjectRunOutcome, String> {
216
+    match command {
217
+        ProjectCommand::List {
218
+            catalog_filter,
219
+            include_deprioritized,
220
+        } => {
221
+            let catalogs = discover_catalogs(default_catalog_root())?;
222
+            print_catalogs(&catalogs, catalog_filter.as_deref(), include_deprioritized);
223
+            Ok(ProjectRunOutcome {
224
+                kept_workdirs: Vec::new(),
225
+                summary_lines: Vec::new(),
226
+                success: true,
227
+            })
228
+        }
229
+        ProjectCommand::Run(config) => run_project(config),
230
+    }
231
+}
232
+
233
+fn default_catalog_root() -> PathBuf {
234
+    Path::new(env!("CARGO_MANIFEST_DIR"))
235
+        .join("..")
236
+        .join("projects")
237
+}
238
+
239
+fn default_project_report_root() -> PathBuf {
240
+    Path::new(env!("CARGO_MANIFEST_DIR"))
241
+        .join("..")
242
+        .join("reports")
243
+        .join("projects")
244
+}
245
+
246
+fn default_project_temp_root() -> PathBuf {
247
+    std::env::temp_dir().join("afs_tests_projects")
248
+}
249
+
250
+fn discover_catalogs(root: PathBuf) -> Result<Vec<ProjectCatalog>, String> {
251
+    let mut files = Vec::new();
252
+    collect_catalog_files(&root, &mut files)?;
253
+    files.sort();
254
+
255
+    let mut catalogs = Vec::new();
256
+    for path in files {
257
+        catalogs.push(parse_catalog_file(&path)?);
258
+    }
259
+    catalogs.sort_by(|a, b| a.name.cmp(&b.name));
260
+    Ok(catalogs)
261
+}
262
+
263
+fn collect_catalog_files(root: &Path, files: &mut Vec<PathBuf>) -> Result<(), String> {
264
+    let entries = fs::read_dir(root).map_err(|e| {
265
+        format!(
266
+            "cannot read project catalog root '{}': {}",
267
+            root.display(),
268
+            e
269
+        )
270
+    })?;
271
+    for entry in entries {
272
+        let entry =
273
+            entry.map_err(|e| format!("cannot read entry in '{}': {}", root.display(), e))?;
274
+        let path = entry.path();
275
+        if path.is_dir() {
276
+            collect_catalog_files(&path, files)?;
277
+        } else if path.extension().and_then(|ext| ext.to_str()) == Some(CATALOG_EXTENSION) {
278
+            files.push(path);
279
+        }
280
+    }
281
+    Ok(())
282
+}
283
+
284
+fn parse_catalog_file(path: &Path) -> Result<ProjectCatalog, String> {
285
+    let text = fs::read_to_string(path)
286
+        .map_err(|e| format!("cannot read project catalog '{}': {}", path.display(), e))?;
287
+
288
+    let mut catalog_name = None;
289
+    let mut projects = Vec::new();
290
+    let mut current = None;
291
+
292
+    for (index, raw_line) in text.lines().enumerate() {
293
+        let line_no = index + 1;
294
+        let line = raw_line.trim();
295
+        if line.is_empty() || line.starts_with('#') {
296
+            continue;
297
+        }
298
+
299
+        if let Some(rest) = line.strip_prefix("campaign ") {
300
+            if catalog_name.is_some() {
301
+                return Err(format!(
302
+                    "{}:{}: duplicate campaign declaration",
303
+                    path.display(),
304
+                    line_no
305
+                ));
306
+            }
307
+            catalog_name = Some(parse_quoted(rest, path, line_no)?);
308
+            continue;
309
+        }
310
+
311
+        if let Some(rest) = line.strip_prefix("project ") {
312
+            if current.is_some() {
313
+                return Err(format!(
314
+                    "{}:{}: nested project without end",
315
+                    path.display(),
316
+                    line_no
317
+                ));
318
+            }
319
+            current = Some(ProjectBuilder::new(parse_quoted(rest, path, line_no)?));
320
+            continue;
321
+        }
322
+
323
+        if line == "end" {
324
+            let builder = current.take().ok_or_else(|| {
325
+                format!(
326
+                    "{}:{}: stray end outside of project",
327
+                    path.display(),
328
+                    line_no
329
+                )
330
+            })?;
331
+            projects.push(builder.build(path)?);
332
+            continue;
333
+        }
334
+
335
+        let builder = current.as_mut().ok_or_else(|| {
336
+            format!(
337
+                "{}:{}: expected campaign/project declaration first",
338
+                path.display(),
339
+                line_no
340
+            )
341
+        })?;
342
+
343
+        if let Some(rest) = line.strip_prefix("source ") {
344
+            builder.source = Some(resolve_catalog_relative_path(rest, path, line_no)?);
345
+        } else if let Some(rest) = line.strip_prefix("native ") {
346
+            builder.native_build = Some(parse_quoted(rest, path, line_no)?);
347
+        } else if let Some(rest) = line.strip_prefix("priority ") {
348
+            builder.priority = Some(parse_usize(rest, path, line_no)?);
349
+        } else if let Some(rest) = line.strip_prefix("status ") {
350
+            builder.status = Some(
351
+                ProjectStatus::parse(rest)
352
+                    .map_err(|err| format!("{}:{}: {}", path.display(), line_no, err))?,
353
+            );
354
+        } else if let Some(rest) = line.strip_prefix("coverage =>") {
355
+            builder.coverage = parse_csv_list(rest);
356
+        } else if let Some(rest) = line.strip_prefix("library_seed =>") {
357
+            builder.library_seed = parse_csv_list(rest);
358
+        } else if let Some(rest) = line.strip_prefix("build =>") {
359
+            builder.build_command = Some(rest.trim().to_string());
360
+        } else if let Some(rest) = line.strip_prefix("test =>") {
361
+            builder.test_command = Some(rest.trim().to_string());
362
+        } else if let Some(rest) = line.strip_prefix("smoke =>") {
363
+            builder.smoke_command = Some(rest.trim().to_string());
364
+        } else if let Some(rest) = line.strip_prefix("note ") {
365
+            builder.notes.push(parse_quoted(rest, path, line_no)?);
366
+        } else {
367
+            return Err(format!(
368
+                "{}:{}: unrecognized project line '{}'",
369
+                path.display(),
370
+                line_no,
371
+                line
372
+            ));
373
+        }
374
+    }
375
+
376
+    if current.is_some() {
377
+        return Err(format!("{}: unterminated project block", path.display()));
378
+    }
379
+
380
+    let name =
381
+        catalog_name.ok_or_else(|| format!("{}: missing campaign declaration", path.display()))?;
382
+    if projects.is_empty() {
383
+        return Err(format!("{}: campaign has no projects", path.display()));
384
+    }
385
+    projects.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.name.cmp(&b.name)));
386
+
387
+    Ok(ProjectCatalog {
388
+        name,
389
+        path: path.to_path_buf(),
390
+        projects,
391
+    })
392
+}
393
+
394
+struct ProjectBuilder {
395
+    name: String,
396
+    source: Option<PathBuf>,
397
+    native_build: Option<String>,
398
+    priority: Option<usize>,
399
+    status: Option<ProjectStatus>,
400
+    coverage: Vec<String>,
401
+    library_seed: Vec<String>,
402
+    build_command: Option<String>,
403
+    test_command: Option<String>,
404
+    smoke_command: Option<String>,
405
+    notes: Vec<String>,
406
+}
407
+
408
+impl ProjectBuilder {
409
+    fn new(name: String) -> Self {
410
+        Self {
411
+            name,
412
+            source: None,
413
+            native_build: None,
414
+            priority: None,
415
+            status: None,
416
+            coverage: Vec::new(),
417
+            library_seed: Vec::new(),
418
+            build_command: None,
419
+            test_command: None,
420
+            smoke_command: None,
421
+            notes: Vec::new(),
422
+        }
423
+    }
424
+
425
+    fn build(self, catalog_path: &Path) -> Result<ProjectSpec, String> {
426
+        Ok(ProjectSpec {
427
+            name: self.name,
428
+            source: self.source.ok_or_else(|| {
429
+                format!("{}: project missing source path", catalog_path.display())
430
+            })?,
431
+            native_build: self.native_build.ok_or_else(|| {
432
+                format!(
433
+                    "{}: project missing native build name",
434
+                    catalog_path.display()
435
+                )
436
+            })?,
437
+            priority: self
438
+                .priority
439
+                .ok_or_else(|| format!("{}: project missing priority", catalog_path.display()))?,
440
+            status: self.status.unwrap_or(ProjectStatus::Active),
441
+            coverage: self.coverage,
442
+            library_seed: self.library_seed,
443
+            build_command: self.build_command.ok_or_else(|| {
444
+                format!("{}: project missing build command", catalog_path.display())
445
+            })?,
446
+            test_command: self.test_command,
447
+            smoke_command: self.smoke_command,
448
+            notes: self.notes,
449
+        })
450
+    }
451
+}
452
+
453
+fn parse_quoted(raw: &str, path: &Path, line_no: usize) -> Result<String, String> {
454
+    let trimmed = raw.trim();
455
+    if !(trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2) {
456
+        return Err(format!(
457
+            "{}:{}: expected quoted string, got '{}'",
458
+            path.display(),
459
+            line_no,
460
+            raw.trim()
461
+        ));
462
+    }
463
+    Ok(trimmed[1..trimmed.len() - 1].to_string())
464
+}
465
+
466
+fn parse_usize(raw: &str, path: &Path, line_no: usize) -> Result<usize, String> {
467
+    raw.trim().parse::<usize>().map_err(|e| {
468
+        format!(
469
+            "{}:{}: expected positive integer, got '{}': {}",
470
+            path.display(),
471
+            line_no,
472
+            raw.trim(),
473
+            e
474
+        )
475
+    })
476
+}
477
+
478
+fn parse_csv_list(raw: &str) -> Vec<String> {
479
+    raw.split(',')
480
+        .map(str::trim)
481
+        .filter(|item| !item.is_empty())
482
+        .map(ToOwned::to_owned)
483
+        .collect()
484
+}
485
+
486
+fn resolve_catalog_relative_path(
487
+    raw: &str,
488
+    path: &Path,
489
+    line_no: usize,
490
+) -> Result<PathBuf, String> {
491
+    let relative = parse_quoted(raw, path, line_no)?;
492
+    let base = path
493
+        .parent()
494
+        .ok_or_else(|| format!("{}:{}: catalog path has no parent", path.display(), line_no))?;
495
+    Ok(base.join(relative))
496
+}
497
+
498
+fn print_catalogs(
499
+    catalogs: &[ProjectCatalog],
500
+    catalog_filter: Option<&str>,
501
+    include_deprioritized: bool,
502
+) {
503
+    for catalog in catalogs {
504
+        if !matches_filter(&catalog.name, catalog_filter) {
505
+            continue;
506
+        }
507
+        println!("campaign {} ({})", catalog.name, catalog.path.display());
508
+        for project in &catalog.projects {
509
+            if project.status == ProjectStatus::Deprioritized && !include_deprioritized {
510
+                continue;
511
+            }
512
+            println!(
513
+                "  {:>2}. {} [{}] {}",
514
+                project.priority,
515
+                project.name,
516
+                project.status.as_str(),
517
+                project.native_build
518
+            );
519
+            if !project.coverage.is_empty() {
520
+                println!("      coverage: {}", project.coverage.join(", "));
521
+            }
522
+            if !project.library_seed.is_empty() {
523
+                println!("      library seeds: {}", project.library_seed.join(", "));
524
+            }
525
+        }
526
+    }
527
+}
528
+
529
+fn matches_filter(value: &str, filter: Option<&str>) -> bool {
530
+    match filter {
531
+        None => true,
532
+        Some(filter) => value
533
+            .to_ascii_lowercase()
534
+            .contains(&filter.to_ascii_lowercase()),
535
+    }
536
+}
537
+
538
+fn run_project(config: ProjectRunConfig) -> Result<ProjectRunOutcome, String> {
539
+    let catalogs = discover_catalogs(default_catalog_root())?;
540
+    let project_name = config.project_filter.as_deref().unwrap_or_default();
541
+
542
+    let mut matches = Vec::new();
543
+    for catalog in &catalogs {
544
+        if !matches_filter(&catalog.name, config.catalog_filter.as_deref()) {
545
+            continue;
546
+        }
547
+        for project in &catalog.projects {
548
+            if matches_filter(&project.name, Some(project_name)) {
549
+                matches.push((catalog.clone(), project.clone()));
550
+            }
551
+        }
552
+    }
553
+
554
+    if matches.is_empty() {
555
+        return Err(format!("no project matched '{}'", project_name));
556
+    }
557
+    if matches.len() > 1 {
558
+        let joined = matches
559
+            .iter()
560
+            .map(|(catalog, project)| format!("{}::{}", catalog.name, project.name))
561
+            .collect::<Vec<_>>()
562
+            .join(", ");
563
+        return Err(format!(
564
+            "project filter '{}' matched multiple projects: {}",
565
+            project_name, joined
566
+        ));
567
+    }
568
+
569
+    let (catalog, project) = matches.pop().unwrap();
570
+    let armfortas_bin = resolve_armfortas_bin(&config.tools)?;
571
+    let flang_bin = config
572
+        .tools
573
+        .reference_binary(crate::ReferenceCompiler::FlangNew)
574
+        .to_string();
575
+    let cc_bin = config.tools.cc_bin().to_string();
576
+
577
+    let mut kept_workdirs = Vec::new();
578
+    let arm_run = run_for_compiler(
579
+        &catalog,
580
+        &project,
581
+        ProjectCompiler::Armfortas,
582
+        &armfortas_bin,
583
+        &cc_bin,
584
+    )?;
585
+    let flang_run = run_for_compiler(
586
+        &catalog,
587
+        &project,
588
+        ProjectCompiler::FlangNew,
589
+        &flang_bin,
590
+        &cc_bin,
591
+    )?;
592
+
593
+    let findings = compare_executions(&arm_run, &flang_run);
594
+    let success = findings.is_empty()
595
+        && arm_run.build.succeeded()
596
+        && flang_run.build.succeeded()
597
+        && arm_run
598
+            .test
599
+            .as_ref()
600
+            .map(|step| step.succeeded())
601
+            .unwrap_or(true)
602
+        && flang_run
603
+            .test
604
+            .as_ref()
605
+            .map(|step| step.succeeded())
606
+            .unwrap_or(true)
607
+        && arm_run
608
+            .smoke
609
+            .as_ref()
610
+            .map(|step| step.succeeded())
611
+            .unwrap_or(true)
612
+        && flang_run
613
+            .smoke
614
+            .as_ref()
615
+            .map(|step| step.succeeded())
616
+            .unwrap_or(true);
617
+
618
+    let report_path = write_project_report(&catalog, &project, &arm_run, &flang_run, &findings)?;
619
+    let summary_lines = render_console_summary(
620
+        &catalog,
621
+        &project,
622
+        &arm_run,
623
+        &flang_run,
624
+        &findings,
625
+        &report_path,
626
+    );
627
+
628
+    if config.keep_workdir || !success {
629
+        kept_workdirs.push(arm_run.workdir.clone());
630
+        kept_workdirs.push(flang_run.workdir.clone());
631
+    } else {
632
+        cleanup_workdir(&arm_run.workdir);
633
+        cleanup_workdir(&flang_run.workdir);
634
+    }
635
+
636
+    Ok(ProjectRunOutcome {
637
+        kept_workdirs,
638
+        summary_lines,
639
+        success,
640
+    })
641
+}
642
+
643
+fn resolve_armfortas_bin(tools: &ToolchainConfig) -> Result<String, String> {
644
+    if let Some(path) = tools.armfortas_external_bin() {
645
+        return Ok(path.to_string());
646
+    }
647
+
648
+    let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
649
+    let workspace_root = manifest_dir.join("..").join("..");
650
+    for candidate in [
651
+        workspace_root.join("target/release/armfortas"),
652
+        workspace_root.join("target/debug/armfortas"),
653
+    ] {
654
+        if candidate.exists() {
655
+            return Ok(candidate.display().to_string());
656
+        }
657
+    }
658
+
659
+    Err(
660
+        "projects run needs an armfortas compiler binary; pass --armfortas-bin or build target/release/armfortas"
661
+            .into(),
662
+    )
663
+}
664
+
665
+fn run_for_compiler(
666
+    catalog: &ProjectCatalog,
667
+    project: &ProjectSpec,
668
+    compiler: ProjectCompiler,
669
+    compiler_bin: &str,
670
+    cc_bin: &str,
671
+) -> Result<ProjectExecution, String> {
672
+    let workdir = prepare_project_workdir(catalog, project, compiler)?;
673
+    let build = run_step(
674
+        "build",
675
+        &project.build_command,
676
+        &workdir,
677
+        compiler_bin,
678
+        cc_bin,
679
+    )?;
680
+    let test = if build.succeeded() {
681
+        match &project.test_command {
682
+            Some(command) => Some(run_step("test", command, &workdir, compiler_bin, cc_bin)?),
683
+            None => None,
684
+        }
685
+    } else {
686
+        None
687
+    };
688
+    let smoke = if build.succeeded() && test.as_ref().map(|step| step.succeeded()).unwrap_or(true) {
689
+        match &project.smoke_command {
690
+            Some(command) => Some(run_step("smoke", command, &workdir, compiler_bin, cc_bin)?),
691
+            None => None,
692
+        }
693
+    } else {
694
+        None
695
+    };
696
+
697
+    Ok(ProjectExecution {
698
+        compiler,
699
+        compiler_bin: compiler_bin.to_string(),
700
+        cc_bin: cc_bin.to_string(),
701
+        workdir,
702
+        build,
703
+        test,
704
+        smoke,
705
+    })
706
+}
707
+
708
+fn prepare_project_workdir(
709
+    catalog: &ProjectCatalog,
710
+    project: &ProjectSpec,
711
+    compiler: ProjectCompiler,
712
+) -> Result<PathBuf, String> {
713
+    let root = default_project_temp_root().join(format!(
714
+        "{}_{}_{}_{}",
715
+        sanitize_component(&catalog.name),
716
+        sanitize_component(&project.name),
717
+        sanitize_component(compiler.as_str()),
718
+        next_project_suffix()
719
+    ));
720
+    if root.exists() {
721
+        fs::remove_dir_all(&root)
722
+            .map_err(|e| format!("cannot clear temp project root '{}': {}", root.display(), e))?;
723
+    }
724
+    fs::create_dir_all(&root).map_err(|e| {
725
+        format!(
726
+            "cannot create temp project root '{}': {}",
727
+            root.display(),
728
+            e
729
+        )
730
+    })?;
731
+    let source_root = root.join("src");
732
+    copy_project_tree(&project.source, &source_root).map_err(|e| {
733
+        format!(
734
+            "cannot copy '{}' into '{}': {}",
735
+            project.source.display(),
736
+            source_root.display(),
737
+            e
738
+        )
739
+    })?;
740
+    Ok(source_root)
741
+}
742
+
743
+fn next_project_suffix() -> String {
744
+    format!(
745
+        "{}-{:04}",
746
+        std::process::id(),
747
+        crate::REPORT_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
748
+    )
749
+}
750
+
751
+fn copy_project_tree(source: &Path, dest: &Path) -> io::Result<()> {
752
+    let metadata = fs::metadata(source)?;
753
+    if metadata.is_dir() {
754
+        fs::create_dir_all(dest)?;
755
+        fs::set_permissions(dest, metadata.permissions())?;
756
+        for entry in fs::read_dir(source)? {
757
+            let entry = entry?;
758
+            let name = entry.file_name();
759
+            if should_skip_copy(&name) {
760
+                continue;
761
+            }
762
+            let child_source = entry.path();
763
+            let child_dest = dest.join(&name);
764
+            copy_project_tree(&child_source, &child_dest)?;
765
+        }
766
+    } else if metadata.is_file() {
767
+        if let Some(parent) = dest.parent() {
768
+            fs::create_dir_all(parent)?;
769
+        }
770
+        fs::copy(source, dest)?;
771
+        fs::set_permissions(dest, metadata.permissions())?;
772
+    }
773
+    Ok(())
774
+}
775
+
776
+fn should_skip_copy(name: &std::ffi::OsStr) -> bool {
777
+    matches!(
778
+        name.to_str(),
779
+        Some(".git")
780
+            | Some("build")
781
+            | Some("bin")
782
+            | Some("target")
783
+            | Some(".fpm")
784
+            | Some("test_results")
785
+            | Some("__pycache__")
786
+    )
787
+}
788
+
789
+fn run_step(
790
+    label: &'static str,
791
+    template: &str,
792
+    workdir: &Path,
793
+    compiler_bin: &str,
794
+    cc_bin: &str,
795
+) -> Result<StepExecution, String> {
796
+    let command = expand_command_template(template, compiler_bin, cc_bin);
797
+    let start = Instant::now();
798
+    let output = Command::new("/bin/zsh")
799
+        .arg("-lc")
800
+        .arg(&command)
801
+        .current_dir(workdir)
802
+        .env("FC", compiler_bin)
803
+        .env("CC", cc_bin)
804
+        .env("ARMFORTAS", compiler_bin)
805
+        .output()
806
+        .map_err(|e| {
807
+            format!(
808
+                "cannot run {} command '{}' in '{}': {}",
809
+                label,
810
+                command,
811
+                workdir.display(),
812
+                e
813
+            )
814
+        })?;
815
+    let duration_ms = start.elapsed().as_millis();
816
+    let exit_code = output.status.code().unwrap_or(-1);
817
+
818
+    Ok(StepExecution {
819
+        label,
820
+        command,
821
+        duration_ms,
822
+        exit_code,
823
+        stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
824
+        stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
825
+    })
826
+}
827
+
828
+fn expand_command_template(template: &str, compiler_bin: &str, cc_bin: &str) -> String {
829
+    template
830
+        .replace("{fc}", compiler_bin)
831
+        .replace("{cc}", cc_bin)
832
+}
833
+
834
+fn compare_executions(
835
+    armfortas: &ProjectExecution,
836
+    flang: &ProjectExecution,
837
+) -> Vec<DifferentialFinding> {
838
+    let mut findings = Vec::new();
839
+    compare_step_outcome(
840
+        "build",
841
+        &armfortas.build,
842
+        &flang.build,
843
+        false,
844
+        &mut findings,
845
+    );
846
+
847
+    match (&armfortas.test, &flang.test) {
848
+        (Some(left), Some(right)) => {
849
+            compare_step_outcome("test", left, right, false, &mut findings)
850
+        }
851
+        (None, Some(_)) | (Some(_), None) => findings.push(DifferentialFinding {
852
+            step: "test",
853
+            detail: "test step only ran for one compiler".into(),
854
+        }),
855
+        (None, None) => {}
856
+    }
857
+
858
+    match (&armfortas.smoke, &flang.smoke) {
859
+        (Some(left), Some(right)) => {
860
+            compare_step_outcome("smoke", left, right, true, &mut findings)
861
+        }
862
+        (None, Some(_)) | (Some(_), None) => findings.push(DifferentialFinding {
863
+            step: "smoke",
864
+            detail: "smoke step only ran for one compiler".into(),
865
+        }),
866
+        (None, None) => {}
867
+    }
868
+
869
+    findings
870
+}
871
+
872
+fn compare_step_outcome(
873
+    step: &'static str,
874
+    left: &StepExecution,
875
+    right: &StepExecution,
876
+    compare_output: bool,
877
+    findings: &mut Vec<DifferentialFinding>,
878
+) {
879
+    if left.succeeded() != right.succeeded() {
880
+        findings.push(DifferentialFinding {
881
+            step,
882
+            detail: format!(
883
+                "{} success diverged: armfortas={} flang-new={}",
884
+                step,
885
+                left.succeeded(),
886
+                right.succeeded()
887
+            ),
888
+        });
889
+        return;
890
+    }
891
+
892
+    if compare_output && left.succeeded() {
893
+        if left.exit_code != right.exit_code {
894
+            findings.push(DifferentialFinding {
895
+                step,
896
+                detail: format!(
897
+                    "{} exit code diverged: armfortas={} flang-new={}",
898
+                    step, left.exit_code, right.exit_code
899
+                ),
900
+            });
901
+        }
902
+        if left.stdout != right.stdout {
903
+            findings.push(DifferentialFinding {
904
+                step,
905
+                detail: format!(
906
+                    "{} stdout diverged (armfortas {} bytes vs flang-new {} bytes)",
907
+                    step,
908
+                    left.stdout.len(),
909
+                    right.stdout.len()
910
+                ),
911
+            });
912
+        }
913
+        if left.stderr != right.stderr {
914
+            findings.push(DifferentialFinding {
915
+                step,
916
+                detail: format!(
917
+                    "{} stderr diverged (armfortas {} bytes vs flang-new {} bytes)",
918
+                    step,
919
+                    left.stderr.len(),
920
+                    right.stderr.len()
921
+                ),
922
+            });
923
+        }
924
+    }
925
+}
926
+
927
+fn write_project_report(
928
+    catalog: &ProjectCatalog,
929
+    project: &ProjectSpec,
930
+    armfortas: &ProjectExecution,
931
+    flang: &ProjectExecution,
932
+    findings: &[DifferentialFinding],
933
+) -> Result<PathBuf, String> {
934
+    let report_root = default_project_report_root()
935
+        .join(sanitize_component(&catalog.name))
936
+        .join(sanitize_component(&project.name));
937
+    fs::create_dir_all(&report_root).map_err(|e| {
938
+        format!(
939
+            "cannot create project report root '{}': {}",
940
+            report_root.display(),
941
+            e
942
+        )
943
+    })?;
944
+
945
+    let report_path = report_root.join(format!("{}.md", next_project_suffix()));
946
+    let mut report = String::new();
947
+    writeln!(&mut report, "# Differential Project Report").unwrap();
948
+    writeln!(&mut report).unwrap();
949
+    writeln!(&mut report, "- Campaign: `{}`", catalog.name).unwrap();
950
+    writeln!(&mut report, "- Project: `{}`", project.name).unwrap();
951
+    writeln!(
952
+        &mut report,
953
+        "- Native build system: `{}`",
954
+        project.native_build
955
+    )
956
+    .unwrap();
957
+    writeln!(&mut report, "- Status: `{}`", project.status.as_str()).unwrap();
958
+    writeln!(&mut report, "- Source: `{}`", project.source.display()).unwrap();
959
+    if !project.coverage.is_empty() {
960
+        writeln!(
961
+            &mut report,
962
+            "- Coverage buckets: `{}`",
963
+            project.coverage.join("`, `")
964
+        )
965
+        .unwrap();
966
+    }
967
+    if !project.library_seed.is_empty() {
968
+        writeln!(
969
+            &mut report,
970
+            "- Library seeds: `{}`",
971
+            project.library_seed.join("`, `")
972
+        )
973
+        .unwrap();
974
+    }
975
+    writeln!(&mut report).unwrap();
976
+
977
+    if !project.notes.is_empty() {
978
+        writeln!(&mut report, "## Notes").unwrap();
979
+        writeln!(&mut report).unwrap();
980
+        for note in &project.notes {
981
+            writeln!(&mut report, "- {}", note).unwrap();
982
+        }
983
+        writeln!(&mut report).unwrap();
984
+    }
985
+
986
+    writeln!(&mut report, "## Differential Summary").unwrap();
987
+    writeln!(&mut report).unwrap();
988
+    if findings.is_empty() {
989
+        writeln!(
990
+            &mut report,
991
+            "- No armfortas-vs-flang-new step outcome differences were observed."
992
+        )
993
+        .unwrap();
994
+    } else {
995
+        for finding in findings {
996
+            writeln!(&mut report, "- `{}`: {}", finding.step, finding.detail).unwrap();
997
+        }
998
+    }
999
+    writeln!(&mut report).unwrap();
1000
+
1001
+    append_execution_report(&mut report, armfortas);
1002
+    append_execution_report(&mut report, flang);
1003
+
1004
+    fs::write(&report_path, report).map_err(|e| {
1005
+        format!(
1006
+            "cannot write project report '{}': {}",
1007
+            report_path.display(),
1008
+            e
1009
+        )
1010
+    })?;
1011
+    Ok(report_path)
1012
+}
1013
+
1014
+fn append_execution_report(report: &mut String, execution: &ProjectExecution) {
1015
+    writeln!(report, "## {}", execution.compiler.as_str()).unwrap();
1016
+    writeln!(report).unwrap();
1017
+    writeln!(report, "- Compiler binary: `{}`", execution.compiler_bin).unwrap();
1018
+    writeln!(report, "- C compiler: `{}`", execution.cc_bin).unwrap();
1019
+    writeln!(report, "- Workdir: `{}`", execution.workdir.display()).unwrap();
1020
+    writeln!(report).unwrap();
1021
+
1022
+    append_step_report(report, &execution.build);
1023
+    if let Some(step) = &execution.test {
1024
+        append_step_report(report, step);
1025
+    }
1026
+    if let Some(step) = &execution.smoke {
1027
+        append_step_report(report, step);
1028
+    }
1029
+}
1030
+
1031
+fn append_step_report(report: &mut String, step: &StepExecution) {
1032
+    writeln!(report, "### {}", step.label.to_ascii_uppercase()).unwrap();
1033
+    writeln!(report).unwrap();
1034
+    writeln!(report, "- Command: `{}`", step.command).unwrap();
1035
+    writeln!(report, "- Duration: `{}` ms", step.duration_ms).unwrap();
1036
+    writeln!(report, "- Exit code: `{}`", step.exit_code).unwrap();
1037
+    writeln!(report).unwrap();
1038
+    writeln!(report, "```text").unwrap();
1039
+    if !step.stdout.is_empty() {
1040
+        write!(report, "stdout:\n{}", step.stdout).unwrap();
1041
+        if !step.stdout.ends_with('\n') {
1042
+            writeln!(report).unwrap();
1043
+        }
1044
+    } else {
1045
+        writeln!(report, "stdout: <empty>").unwrap();
1046
+    }
1047
+    if !step.stderr.is_empty() {
1048
+        write!(report, "stderr:\n{}", step.stderr).unwrap();
1049
+        if !step.stderr.ends_with('\n') {
1050
+            writeln!(report).unwrap();
1051
+        }
1052
+    } else {
1053
+        writeln!(report, "stderr: <empty>").unwrap();
1054
+    }
1055
+    writeln!(report, "```").unwrap();
1056
+    writeln!(report).unwrap();
1057
+}
1058
+
1059
+fn render_console_summary(
1060
+    catalog: &ProjectCatalog,
1061
+    project: &ProjectSpec,
1062
+    armfortas: &ProjectExecution,
1063
+    flang: &ProjectExecution,
1064
+    findings: &[DifferentialFinding],
1065
+    report_path: &Path,
1066
+) -> Vec<String> {
1067
+    let mut lines = Vec::new();
1068
+    lines.push(format!(
1069
+        "PROJECT {}::{} ({})",
1070
+        catalog.name, project.name, project.native_build
1071
+    ));
1072
+    lines.push(step_console_line(
1073
+        "armfortas",
1074
+        &armfortas.build,
1075
+        armfortas.test.as_ref(),
1076
+        armfortas.smoke.as_ref(),
1077
+    ));
1078
+    lines.push(step_console_line(
1079
+        "flang-new",
1080
+        &flang.build,
1081
+        flang.test.as_ref(),
1082
+        flang.smoke.as_ref(),
1083
+    ));
1084
+    if findings.is_empty() {
1085
+        lines.push("differential: no step outcome differences observed".into());
1086
+    } else {
1087
+        lines.push(format!("differential: {} finding(s)", findings.len()));
1088
+        for finding in findings {
1089
+            lines.push(format!("  - {}: {}", finding.step, finding.detail));
1090
+        }
1091
+    }
1092
+    lines.push(format!("report: {}", report_path.display()));
1093
+    lines
1094
+}
1095
+
1096
+fn step_console_line(
1097
+    compiler: &str,
1098
+    build: &StepExecution,
1099
+    test: Option<&StepExecution>,
1100
+    smoke: Option<&StepExecution>,
1101
+) -> String {
1102
+    let mut parts = Vec::new();
1103
+    parts.push(format!(
1104
+        "build={}({}ms)",
1105
+        status_word(build.succeeded()),
1106
+        build.duration_ms
1107
+    ));
1108
+    if let Some(step) = test {
1109
+        parts.push(format!(
1110
+            "test={}({}ms)",
1111
+            status_word(step.succeeded()),
1112
+            step.duration_ms
1113
+        ));
1114
+    }
1115
+    if let Some(step) = smoke {
1116
+        parts.push(format!(
1117
+            "smoke={}({}ms)",
1118
+            status_word(step.succeeded()),
1119
+            step.duration_ms
1120
+        ));
1121
+    }
1122
+    format!("{} {}", compiler, parts.join(" "))
1123
+}
1124
+
1125
+fn status_word(ok: bool) -> &'static str {
1126
+    if ok {
1127
+        "PASS"
1128
+    } else {
1129
+        "FAIL"
1130
+    }
1131
+}
1132
+
1133
+fn cleanup_workdir(path: &Path) {
1134
+    let _ = fs::remove_dir_all(path);
1135
+}
1136
+
1137
+#[cfg(test)]
1138
+mod tests {
1139
+    use super::*;
1140
+
1141
+    #[test]
1142
+    fn parses_project_catalog() {
1143
+        let root = std::env::temp_dir().join("afs_tests_project_catalog");
1144
+        let _ = fs::remove_dir_all(&root);
1145
+        fs::create_dir_all(root.join("projects")).unwrap();
1146
+        fs::write(
1147
+            root.join("projects").join("demo.afproj"),
1148
+            r#"campaign "demo"
1149
+
1150
+project "fortbite"
1151
+source "../fortbite"
1152
+native "make"
1153
+priority 1
1154
+status active
1155
+coverage => pure_semantics, performance
1156
+library_seed => fortargs, fortds
1157
+build => make clean && make FC="{fc}"
1158
+test => make test FC="{fc}"
1159
+smoke => printf '2 + 3\nquit\n' | ./build/bin/fortbite
1160
+note "Pure Fortran calculator target."
1161
+end
1162
+"#,
1163
+        )
1164
+        .unwrap();
1165
+
1166
+        let catalog = parse_catalog_file(&root.join("projects").join("demo.afproj")).unwrap();
1167
+        assert_eq!(catalog.name, "demo");
1168
+        assert_eq!(catalog.projects.len(), 1);
1169
+        let project = &catalog.projects[0];
1170
+        assert_eq!(project.name, "fortbite");
1171
+        assert_eq!(project.native_build, "make");
1172
+        assert_eq!(project.priority, 1);
1173
+        assert_eq!(project.status, ProjectStatus::Active);
1174
+        assert_eq!(project.coverage, vec!["pure_semantics", "performance"]);
1175
+        assert_eq!(project.library_seed, vec!["fortargs", "fortds"]);
1176
+        assert_eq!(project.build_command, "make clean && make FC=\"{fc}\"");
1177
+        assert_eq!(project.notes, vec!["Pure Fortran calculator target."]);
1178
+
1179
+        let _ = fs::remove_dir_all(&root);
1180
+    }
1181
+
1182
+    #[test]
1183
+    fn expands_project_command_template() {
1184
+        let expanded = expand_command_template(
1185
+            "make FC=\"{fc}\" CC=\"{cc}\"",
1186
+            "/tmp/armfortas",
1187
+            "/usr/bin/clang",
1188
+        );
1189
+        assert_eq!(expanded, "make FC=\"/tmp/armfortas\" CC=\"/usr/bin/clang\"");
1190
+    }
1191
+}
projects/fortrangoingonforty.afprojadded
@@ -0,0 +1,89 @@
1
+campaign "fortrangoingonforty"
2
+
3
+project "fortbite"
4
+source "../../../fortbite"
5
+native "make"
6
+priority 1
7
+status active
8
+coverage => pure_semantics, performance
9
+library_seed => fortds
10
+build => make clean && make FC="{fc}" FFLAGS="-O2"
11
+test => make test FC="{fc}" FFLAGS="-O2"
12
+smoke => printf '2 + 3\nquit\n' | ./build/bin/fortbite
13
+note "Pure Fortran parser, AST, evaluator, and numeric semantics target."
14
+note "Best first differential target after fortsh because it keeps interop noise low."
15
+end
16
+
17
+project "ferp"
18
+source "../../../ferp"
19
+native "make"
20
+priority 2
21
+status active
22
+coverage => pure_semantics, system_interop, performance
23
+library_seed => fortargs, fortpath, fortconfig
24
+build => make clean && make FC="{fc}" CC="{cc}"
25
+test => make test FC="{fc}" CC="{cc}"
26
+smoke => printf 'hello world\n' | ./ferp hello
27
+note "Regex, CLI, and filesystem tool with light C interop."
28
+note "Second differential target once pure semantics are stable."
29
+end
30
+
31
+project "sniffert"
32
+source "../../../sniffert"
33
+native "make"
34
+priority 3
35
+status active
36
+coverage => system_interop, interactive, performance
37
+library_seed => fortterm, fortpath
38
+build => make clean && make FC="{fc}" CC="{cc}"
39
+note "First ncurses-flavored systems TUI target after fortbite and ferp."
40
+end
41
+
42
+project "fortress"
43
+source "../../../fortress"
44
+native "fpm"
45
+priority 4
46
+status active
47
+coverage => interactive, system_interop
48
+library_seed => fortterm, fortpath, fortproc
49
+build => fpm build --compiler "{fc}"
50
+test => fpm test --compiler "{fc}"
51
+note "File-explorer TUI target with ncurses and shell integration surfaces."
52
+end
53
+
54
+project "fit"
55
+source "../../../fit"
56
+native "make"
57
+priority 5
58
+status active
59
+coverage => interactive, pure_semantics
60
+library_seed => fortterm, fortds
61
+build => make clean && make FC="{fc}"
62
+test => make test FC="{fc}"
63
+note "Smaller editor-like TUI target for merge-conflict workflows."
64
+end
65
+
66
+project "facsimile"
67
+source "../../../facsimile"
68
+native "make"
69
+priority 6
70
+status active
71
+coverage => cross_tu, system_interop, interactive, performance
72
+library_seed => fortterm, fortpath, fortproc, fortds, fortconfig
73
+build => make clean && make FC="{fc}" CC="{cc}"
74
+smoke => ./fac --version
75
+note "Next flagship proof target after the smaller ladder is mostly green."
76
+note "Expected to expose terminal, regex, LSP, UTF-8, and cross-module ABI gaps."
77
+end
78
+
79
+project "fortty"
80
+source "../../../fortty"
81
+native "cmake"
82
+priority 7
83
+status later
84
+coverage => system_interop, interactive, performance
85
+library_seed => fortterm
86
+build => cmake -S . -B build -DCMAKE_Fortran_COMPILER="{fc}" -DCMAKE_C_COMPILER="{cc}" && cmake --build build -j1
87
+note "Later graphics/interoperability target with GLFW, OpenGL, and FreeType."
88
+note "Keep this later in the ladder so dependency noise does not mask compiler issues too early."
89
+end