@@ -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 | +} |