| 1 | //! Sprint 32 CLI driver tests. |
| 2 | //! |
| 3 | //! Each test exercises one user-visible behaviour of the `armfortas` |
| 4 | //! / `afs` driver via subprocess invocation. Subprocess use is |
| 5 | //! deliberate — we want to catch wrong-exit-code, wrong-stdout-vs- |
| 6 | //! stderr-routing, and missing-symbol-from-bin issues that an |
| 7 | //! in-process API call wouldn't see. |
| 8 | |
| 9 | use std::path::PathBuf; |
| 10 | use std::process::Command; |
| 11 | |
| 12 | fn compiler(name: &str) -> PathBuf { |
| 13 | let candidate = PathBuf::from("target/release").join(name); |
| 14 | if candidate.exists() { |
| 15 | return candidate; |
| 16 | } |
| 17 | let candidate = PathBuf::from("target/debug").join(name); |
| 18 | assert!( |
| 19 | candidate.exists(), |
| 20 | "compiler binary '{}' not built — run `cargo build --bins` first", |
| 21 | name |
| 22 | ); |
| 23 | candidate |
| 24 | } |
| 25 | |
| 26 | fn unique_path(stem: &str, ext: &str) -> PathBuf { |
| 27 | let pid = std::process::id(); |
| 28 | let nanos = std::time::SystemTime::now() |
| 29 | .duration_since(std::time::UNIX_EPOCH) |
| 30 | .unwrap() |
| 31 | .as_nanos(); |
| 32 | std::env::temp_dir().join(format!("afs_cli_{}_{}_{}.{}", stem, pid, nanos, ext)) |
| 33 | } |
| 34 | |
| 35 | fn write_program(text: &str, suffix: &str) -> PathBuf { |
| 36 | let path = unique_path("src", suffix); |
| 37 | std::fs::write(&path, text).expect("cannot write CLI test source"); |
| 38 | path |
| 39 | } |
| 40 | |
| 41 | #[test] |
| 42 | fn version_flag_prints_version_string_to_stdout() { |
| 43 | let out = Command::new(compiler("armfortas")) |
| 44 | .arg("--version") |
| 45 | .output() |
| 46 | .expect("failed to spawn armfortas"); |
| 47 | assert!(out.status.success(), "exit code: {:?}", out.status); |
| 48 | let stdout = String::from_utf8_lossy(&out.stdout); |
| 49 | assert!( |
| 50 | stdout.contains("armfortas") && stdout.contains("0.1.0"), |
| 51 | "unexpected --version output: {}", |
| 52 | stdout |
| 53 | ); |
| 54 | // The version string belongs on stdout (not stderr) per |
| 55 | // gfortran/clang convention; users shell-pipe it. |
| 56 | assert!(out.stderr.is_empty(), "stderr should be empty: {:?}", String::from_utf8_lossy(&out.stderr)); |
| 57 | } |
| 58 | |
| 59 | #[test] |
| 60 | fn help_flag_shows_usage_and_exits_zero() { |
| 61 | let out = Command::new(compiler("armfortas")) |
| 62 | .arg("--help") |
| 63 | .output() |
| 64 | .expect("failed to spawn armfortas"); |
| 65 | assert!(out.status.success(), "--help should succeed"); |
| 66 | let stdout = String::from_utf8_lossy(&out.stdout); |
| 67 | assert!(stdout.contains("USAGE"), "help missing USAGE line"); |
| 68 | assert!(stdout.contains("--std="), "help missing --std= entry"); |
| 69 | } |
| 70 | |
| 71 | #[test] |
| 72 | fn dumpversion_prints_just_the_version_number() { |
| 73 | let out = Command::new(compiler("armfortas")) |
| 74 | .arg("-dumpversion") |
| 75 | .output() |
| 76 | .expect("failed to spawn armfortas"); |
| 77 | assert!(out.status.success()); |
| 78 | let stdout = String::from_utf8_lossy(&out.stdout); |
| 79 | assert_eq!(stdout.trim(), "0.1.0"); |
| 80 | } |
| 81 | |
| 82 | #[test] |
| 83 | fn afs_alias_runs_the_same_compiler() { |
| 84 | let out = Command::new(compiler("afs")) |
| 85 | .arg("--version") |
| 86 | .output() |
| 87 | .expect("failed to spawn afs alias"); |
| 88 | assert!(out.status.success()); |
| 89 | let stdout = String::from_utf8_lossy(&out.stdout); |
| 90 | // Both binaries are built from the same source so the version |
| 91 | // string is identical — that's the contract. |
| 92 | assert!(stdout.contains("armfortas")); |
| 93 | } |
| 94 | |
| 95 | #[test] |
| 96 | fn no_args_prints_help_to_stderr_and_exits_nonzero() { |
| 97 | let out = Command::new(compiler("armfortas")) |
| 98 | .output() |
| 99 | .expect("failed to spawn armfortas"); |
| 100 | assert!(!out.status.success(), "no-arg invocation should fail"); |
| 101 | let stderr = String::from_utf8_lossy(&out.stderr); |
| 102 | assert!( |
| 103 | stderr.contains("USAGE"), |
| 104 | "no-arg invocation should print help to stderr: {}", |
| 105 | stderr |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | #[test] |
| 110 | fn dash_c_produces_object_file_only() { |
| 111 | let src = write_program( |
| 112 | "module foo\n integer :: x = 1\nend module\n", |
| 113 | "f90", |
| 114 | ); |
| 115 | let out = unique_path("obj", "o"); |
| 116 | let result = Command::new(compiler("armfortas")) |
| 117 | .args([ |
| 118 | "-c", |
| 119 | src.to_str().unwrap(), |
| 120 | "-o", |
| 121 | out.to_str().unwrap(), |
| 122 | ]) |
| 123 | .output() |
| 124 | .expect("compile failed to spawn"); |
| 125 | assert!( |
| 126 | result.status.success(), |
| 127 | "-c compile failed: {}", |
| 128 | String::from_utf8_lossy(&result.stderr) |
| 129 | ); |
| 130 | assert!(out.exists(), "-c should produce an object file"); |
| 131 | let _ = std::fs::remove_file(&out); |
| 132 | let _ = std::fs::remove_file(&src); |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn dash_capital_s_produces_assembly_text() { |
| 137 | let src = write_program( |
| 138 | "program p\n print *, 1\nend program\n", |
| 139 | "f90", |
| 140 | ); |
| 141 | let out = unique_path("asm", "s"); |
| 142 | let result = Command::new(compiler("armfortas")) |
| 143 | .args([ |
| 144 | "-S", |
| 145 | src.to_str().unwrap(), |
| 146 | "-o", |
| 147 | out.to_str().unwrap(), |
| 148 | ]) |
| 149 | .output() |
| 150 | .expect("spawn failed"); |
| 151 | assert!( |
| 152 | result.status.success(), |
| 153 | "-S compile failed: {}", |
| 154 | String::from_utf8_lossy(&result.stderr) |
| 155 | ); |
| 156 | let asm = std::fs::read_to_string(&out).expect("missing asm output"); |
| 157 | assert!(asm.contains("__TEXT"), ".s output should contain section directive"); |
| 158 | let _ = std::fs::remove_file(&out); |
| 159 | let _ = std::fs::remove_file(&src); |
| 160 | } |
| 161 | |
| 162 | #[test] |
| 163 | fn dash_capital_e_preprocesses_only() { |
| 164 | let src = write_program( |
| 165 | "#define X 99\nprogram p\n print *, X\nend program\n", |
| 166 | "F90", |
| 167 | ); |
| 168 | let out = unique_path("pp", "f90"); |
| 169 | let result = Command::new(compiler("armfortas")) |
| 170 | .args([ |
| 171 | "-E", |
| 172 | src.to_str().unwrap(), |
| 173 | "-o", |
| 174 | out.to_str().unwrap(), |
| 175 | ]) |
| 176 | .output() |
| 177 | .expect("spawn failed"); |
| 178 | assert!( |
| 179 | result.status.success(), |
| 180 | "-E preprocess failed: {}", |
| 181 | String::from_utf8_lossy(&result.stderr) |
| 182 | ); |
| 183 | let pp = std::fs::read_to_string(&out).expect("missing preprocessed output"); |
| 184 | assert!( |
| 185 | pp.contains(", 99"), |
| 186 | "preprocessed text should expand the macro: {}", |
| 187 | pp |
| 188 | ); |
| 189 | let _ = std::fs::remove_file(&out); |
| 190 | let _ = std::fs::remove_file(&src); |
| 191 | } |
| 192 | |
| 193 | #[test] |
| 194 | fn std_f95_rejects_f2008_error_stop() { |
| 195 | let src = write_program( |
| 196 | "program p\n error stop 'oops'\nend program\n", |
| 197 | "f90", |
| 198 | ); |
| 199 | let out = unique_path("f95", "bin"); |
| 200 | let result = Command::new(compiler("armfortas")) |
| 201 | .args([ |
| 202 | "--std=f95", |
| 203 | src.to_str().unwrap(), |
| 204 | "-o", |
| 205 | out.to_str().unwrap(), |
| 206 | ]) |
| 207 | .output() |
| 208 | .expect("spawn failed"); |
| 209 | assert!(!result.status.success(), "--std=f95 should reject ERROR STOP"); |
| 210 | let stderr = String::from_utf8_lossy(&result.stderr); |
| 211 | assert!( |
| 212 | stderr.contains("ERROR STOP") && stderr.contains("F2008"), |
| 213 | "expected ERROR STOP / F2008 error: {}", |
| 214 | stderr |
| 215 | ); |
| 216 | let _ = std::fs::remove_file(&src); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn response_file_supplies_arguments() { |
| 221 | let src = write_program( |
| 222 | "program p\n print *, 7\nend program\n", |
| 223 | "f90", |
| 224 | ); |
| 225 | let out = unique_path("resp", "bin"); |
| 226 | let resp = unique_path("flags", "txt"); |
| 227 | std::fs::write( |
| 228 | &resp, |
| 229 | format!( |
| 230 | "-O1\n-o\n{}\n{}\n", |
| 231 | out.display(), |
| 232 | src.display() |
| 233 | ), |
| 234 | ) |
| 235 | .unwrap(); |
| 236 | let result = Command::new(compiler("armfortas")) |
| 237 | .arg(format!("@{}", resp.display())) |
| 238 | .output() |
| 239 | .expect("spawn failed"); |
| 240 | assert!( |
| 241 | result.status.success(), |
| 242 | "@response-file compile failed: {}", |
| 243 | String::from_utf8_lossy(&result.stderr) |
| 244 | ); |
| 245 | assert!(out.exists(), "binary should exist after @file compile"); |
| 246 | let _ = std::fs::remove_file(&out); |
| 247 | let _ = std::fs::remove_file(&src); |
| 248 | let _ = std::fs::remove_file(&resp); |
| 249 | } |
| 250 | |
| 251 | #[test] |
| 252 | fn dash_j_writes_amod_to_chosen_directory() { |
| 253 | let src = write_program( |
| 254 | "module dashj_mod\n integer :: y = 5\nend module\n", |
| 255 | "f90", |
| 256 | ); |
| 257 | let out = unique_path("dashjobj", "o"); |
| 258 | let amod_dir = std::env::temp_dir().join(format!( |
| 259 | "afs_cli_amod_{}_{}", |
| 260 | std::process::id(), |
| 261 | std::time::SystemTime::now() |
| 262 | .duration_since(std::time::UNIX_EPOCH) |
| 263 | .unwrap() |
| 264 | .as_nanos(), |
| 265 | )); |
| 266 | std::fs::create_dir_all(&amod_dir).unwrap(); |
| 267 | let result = Command::new(compiler("armfortas")) |
| 268 | .args([ |
| 269 | "-c", |
| 270 | "-J", |
| 271 | amod_dir.to_str().unwrap(), |
| 272 | src.to_str().unwrap(), |
| 273 | "-o", |
| 274 | out.to_str().unwrap(), |
| 275 | ]) |
| 276 | .output() |
| 277 | .expect("spawn failed"); |
| 278 | assert!( |
| 279 | result.status.success(), |
| 280 | "-J compile failed: {}", |
| 281 | String::from_utf8_lossy(&result.stderr) |
| 282 | ); |
| 283 | let amod = amod_dir.join("dashj_mod.amod"); |
| 284 | assert!(amod.exists(), "-J should place .amod in the requested dir"); |
| 285 | let _ = std::fs::remove_file(&out); |
| 286 | let _ = std::fs::remove_file(&src); |
| 287 | let _ = std::fs::remove_dir_all(&amod_dir); |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn verbose_flag_streams_phase_lines_to_stderr() { |
| 292 | let src = write_program( |
| 293 | "program p\n print *, 1\nend program\n", |
| 294 | "f90", |
| 295 | ); |
| 296 | let out = unique_path("verbose", "bin"); |
| 297 | let result = Command::new(compiler("armfortas")) |
| 298 | .args([ |
| 299 | "-v", |
| 300 | src.to_str().unwrap(), |
| 301 | "-o", |
| 302 | out.to_str().unwrap(), |
| 303 | ]) |
| 304 | .output() |
| 305 | .expect("spawn failed"); |
| 306 | assert!(result.status.success()); |
| 307 | let stderr = String::from_utf8_lossy(&result.stderr); |
| 308 | assert!(stderr.contains("preprocessing:"), "verbose missing preprocessing line: {}", stderr); |
| 309 | assert!(stderr.contains("codegen:"), "verbose missing codegen line: {}", stderr); |
| 310 | let _ = std::fs::remove_file(&out); |
| 311 | let _ = std::fs::remove_file(&src); |
| 312 | } |
| 313 | |
| 314 | #[test] |
| 315 | fn time_report_prints_phase_table() { |
| 316 | let src = write_program( |
| 317 | "program p\n print *, 1\nend program\n", |
| 318 | "f90", |
| 319 | ); |
| 320 | let out = unique_path("timer", "bin"); |
| 321 | let result = Command::new(compiler("armfortas")) |
| 322 | .args([ |
| 323 | "--time-report", |
| 324 | src.to_str().unwrap(), |
| 325 | "-o", |
| 326 | out.to_str().unwrap(), |
| 327 | ]) |
| 328 | .output() |
| 329 | .expect("spawn failed"); |
| 330 | assert!(result.status.success()); |
| 331 | let stderr = String::from_utf8_lossy(&result.stderr); |
| 332 | assert!(stderr.contains("Phase"), "missing time-report header: {}", stderr); |
| 333 | assert!(stderr.contains("Total"), "missing time-report total: {}", stderr); |
| 334 | let _ = std::fs::remove_file(&out); |
| 335 | let _ = std::fs::remove_file(&src); |
| 336 | } |
| 337 | |
| 338 | #[test] |
| 339 | fn missing_input_file_reports_io_error() { |
| 340 | let result = Command::new(compiler("armfortas")) |
| 341 | .args(["/nonexistent/path/source.f90"]) |
| 342 | .output() |
| 343 | .expect("spawn failed"); |
| 344 | assert!(!result.status.success(), "missing input should fail"); |
| 345 | // Per sprint 32 #6 exit-code spec: I/O errors (cannot read input) |
| 346 | // map to exit code 3. The driver categorises by error message |
| 347 | // text today; a structured error type is sprint 32 #507. |
| 348 | assert_eq!( |
| 349 | result.status.code(), |
| 350 | Some(3), |
| 351 | "missing input should map to exit code 3 (I/O error), got: {:?}", |
| 352 | result.status |
| 353 | ); |
| 354 | } |
| 355 |