//! Revisionsgebundene Exportaufträge und abwerfbare Hintergrundresultate. use crate::documents::{Destination, DocumentId, Project}; use anyhow::{ensure, Result}; use std::path::PathBuf; use tb_vm::project_io::{has_extension, Manifest}; pub const UNAVAILABLE: &str = "Native Erzeugung folgt in Phase 6"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Artifact { Executable, Library, } impl Artifact { pub fn label(self) -> &'static str { match self { Self::Executable => "Natives Standalone-Executable", Self::Library => "Portable P-Code-Bibliothek (.tbl)", } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectStamp { pub path: Option, pub manifest: Manifest, pub inputs: Vec<(PathBuf, Option>)>, pub include_paths: Vec, pub revisions: Vec<(DocumentId, u64)>, } impl ProjectStamp { pub fn capture(project: &Project) -> Self { Self { path: project.path().map(PathBuf::from), manifest: project.manifest().clone(), include_paths: project.include_paths.clone(), inputs: project .sources() .map(|s| { s.protected_inputs() .into_iter() .filter(|p| has_extension(p, "tbl") || project.find_document(p).is_none()) .map(|p| { let b = std::fs::read(&p).ok(); (p, b) }) .collect() }) .unwrap_or_default(), revisions: project .documents() .map(|(id, d)| (id, d.revision())) .collect(), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExportRequest { pub generation: u64, pub project: ProjectStamp, pub artifact: Artifact, pub system: String, pub architecture: String, pub path: PathBuf, pub overwrite: bool, pub previous_output: Option>, } impl ExportRequest { pub fn validate( project: &Project, artifact: Artifact, system: &str, architecture: &str, destination: &Destination, ) -> Result { if artifact == Artifact::Executable { target(system, architecture)?; } ensure!( !has_extension(&destination.path, "tbc"), "TBC ist kein natives Exportartefakt" ); let path = project.validate_export_target(destination)?; if artifact == Artifact::Library { ensure!(has_extension(&path, "tbl"), "Library-Ausgabe benötigt .tbl"); } static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); Ok(Self { generation: NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), project: ProjectStamp::capture(project), artifact, system: system.into(), architecture: architecture.into(), previous_output: std::fs::read(&path).ok(), path, overwrite: destination.overwrite, }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExportStatus { Ready, Running, Success, Failed(String), Cancelled, } impl ExportStatus { pub fn text(&self) -> String { match self { Self::Ready => "Geprüft".into(), Self::Running => "Erzeugung läuft".into(), Self::Success => "Erfolgreich erzeugt".into(), Self::Failed(e) => format!("Erzeugung fehlgeschlagen: {e}"), Self::Cancelled => "Erzeugung abgebrochen".into(), } } } /// Die unterstützten Kombinationen werden ausschließlich aus dem Exportkatalog gewählt. pub fn target(system: &str, architecture: &str) -> Result { tb_export::Target::ALL .into_iter() .find(|t| target_parts(*t) == (system, architecture)) .ok_or_else(|| anyhow::anyhow!("Unzulässiges EXE-Ziel: {system}/{architecture}")) } pub fn target_parts(target: tb_export::Target) -> (&'static str, &'static str) { let triple = target.triple(); ( if triple.contains("windows") { "windows" } else if triple.contains("apple") { "macos" } else { "linux" }, triple.split('-').next().unwrap(), ) } pub struct Job { pub request: ExportRequest, thread: Option>, cancelled: std::sync::Arc, result: std::sync::mpsc::Receiver>, } impl Drop for Job { fn drop(&mut self) { self.cancelled .store(true, std::sync::atomic::Ordering::Release); // Auch beim Beenden der IDE müssen kontrollierte Tools und Staging enden. if let Some(thread) = self.thread.take() { let _ = thread.join(); } } } impl Job { pub fn start( request: ExportRequest, project: &Project, runtime_dir: &std::path::Path, ) -> Result { let sources = project.sources()?; let mut compiler = tb_vm::project::ProjectCompiler::default(); compiler.debug_symbols = true; let diagnostics = |errors: Vec| { anyhow::anyhow!(errors .iter() .map(ToString::to_string) .collect::>() .join("\n")) }; let bytes = match request.artifact { Artifact::Library => sources .compile_library(&mut compiler) .map_err(diagnostics)? .to_tbl() .map_err(|e| anyhow::anyhow!(e))?, Artifact::Executable => sources .compile(&mut compiler, "IDE") .map_err(diagnostics)? .to_tbc(), }; ensure!( request.project == ProjectStamp::capture(project), "Projekt während der Übersetzung geändert" ); let mut protected = sources.protected_inputs(); protected.extend( project .documents() .map(|(_, d)| d.source_path().to_path_buf()), ); if let Some(path) = project.path() { protected.push(path.into()); } let runtime_dir = runtime_dir.to_path_buf(); let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let flag = cancelled.clone(); let (sender, result) = std::sync::mpsc::channel(); let work = request.clone(); let thread = std::thread::Builder::new() .name("tb-export".into()) .spawn(move || { let cancelled = || flag.load(std::sync::atomic::Ordering::Acquire); let prepare = || -> Result { if work.artifact == Artifact::Library { tb_export::prepare_publication( &work.path, work.overwrite, &protected, &cancelled, |p| { std::fs::write(p, &bytes)?; Ok(()) }, ) } else { let target = target(&work.system, &work.architecture)?; let template = runtime_dir .join(target.triple()) .join(target.runtime_name()); let module = tb_vm::bytecode::CompiledModule::from_tbc(&bytes) .map_err(|e| anyhow::anyhow!("{e}"))?; tb_export::prepare_export( tb_export::Export { module: &module, target, template: &template, output: &work.path, overwrite: work.overwrite, protected: &protected, }, &cancelled, ) } }; // Ein geschlossener Empfänger verwirft das Staging-Ergebnis per Drop. let _ = sender.send(prepare()); })?; Ok(Self { thread: Some(thread), request, cancelled, result, }) } pub fn take_result(&self) -> Option> { match self.result.try_recv() { Ok(result) => Some(result), Err(std::sync::mpsc::TryRecvError::Empty) => None, Err(_) => Some(Err(anyhow::anyhow!("Exportworker beendet ohne Ergebnis"))), } } } #[cfg(test)] mod tests { use super::*; use crate::{app::App, commands::Command}; use std::{ fs, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, mpsc, Arc, }, }; struct Dir(PathBuf); impl Dir { fn new() -> Self { static N: AtomicU64 = AtomicU64::new(0); let p = std::env::temp_dir().join(format!( "tb-export-ui-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed) )); fs::create_dir_all(&p).unwrap(); Self(p.canonicalize().unwrap()) } } impl Drop for Dir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn delayed( app: &mut App, output: &std::path::Path, ) -> ( mpsc::Sender>, Arc, ) { app.execute(Command::MakeLibrary); let request = ExportRequest::validate( &app.project, Artifact::Library, "", "", &Destination { path: output.into(), overwrite: true, }, ) .unwrap(); let d = app.dialog.as_mut().unwrap(); d.request = Some(request.clone()); d.export_status = ExportStatus::Running; let (tx, rx) = mpsc::channel(); let cancelled = Arc::new(AtomicBool::new(false)); app.export_job = Some(Job { thread: None, request, cancelled: cancelled.clone(), result: rx, }); (tx, cancelled) } fn prepared(output: &std::path::Path) -> tb_export::PreparedPublication { tb_export::prepare_publication(output, true, &[], &|| false, |p| { fs::write(p, b"new")?; Ok(()) }) .unwrap() } fn no_staging(dir: &std::path::Path) { assert!(fs::read_dir(dir).unwrap().all(|p| !p .unwrap() .file_name() .to_string_lossy() .starts_with(".tb-export-"))); } #[test] fn delayed_result_is_published_only_after_ui_validation_and_success_stays_success() { let dir = Dir::new(); let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap(); let output = dir.0.join("out.tbl"); fs::write(&output, b"old").unwrap(); let (tx, _) = delayed(&mut app, &output); let staged = prepared(&output); app.tick_export(); app.handle(crossterm::event::Event::Resize(110, 35)); assert_eq!(app.size, (110, 35)); assert_eq!(fs::read(&output).unwrap(), b"old"); tx.send(Ok(staged)).ok().unwrap(); app.tick_export(); assert_eq!(fs::read(&output).unwrap(), b"new"); assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success); app.handle(crossterm::event::Event::Key( crossterm::event::KeyEvent::new( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::NONE, ), )); assert_eq!(app.last_export.as_ref().unwrap().1, ExportStatus::Success); no_staging(&dir.0); } #[test] fn cancelled_superseded_changed_and_foreign_jobs_never_publish() { for mode in 0..5 { let dir = Dir::new(); let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap(); let output = dir.0.join("out.tbl"); fs::write(&output, b"old").unwrap(); let (tx, flag) = delayed(&mut app, &output); let staged = prepared(&output); match mode { 0 => app.handle(crossterm::event::Event::Key( crossterm::event::KeyEvent::new( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::NONE, ), )), 1 => { let id = app.project.members()[0]; app.project.replace_text(id, 0..0, "PRINT 2\n").unwrap(); } 2 => { app.project = Project::new(&dir.0).unwrap(); } 3 => { let old = app.export_job.as_ref().unwrap().request.generation; let (_new, _) = delayed(&mut app, &output); assert_ne!(old, app.export_job.as_ref().unwrap().request.generation); } _ => { fs::write(&output, b"external").unwrap(); } } let _ = tx.send(Ok(staged)); app.tick_export(); assert!(flag.load(Ordering::Acquire)); assert_eq!( fs::read(&output).unwrap(), if mode == 4 { b"external".as_slice() } else { b"old".as_slice() } ); assert!(!app .last_export .as_ref() .is_some_and(|(_, s)| *s == ExportStatus::Success)); no_staging(&dir.0); } } #[test] fn replaced_library_invalidates_pending_export_even_with_same_length() { let dir = Dir::new(); let source = dir.0.join("lib.bas"); fs::write(&source, "SUB Answer\nPRINT 1\nEND SUB\n").unwrap(); let mut compiler = tb_vm::project::ProjectCompiler::default(); let build = |compiler: &mut tb_vm::project::ProjectCompiler| { tb_vm::project_io::SourceLoader::default() .load(&source) .unwrap() .compile_library(compiler) .unwrap() .to_tbl() .unwrap() }; let a = build(&mut compiler); fs::write(&source, "SUB Answer\nPRINT 2\nEND SUB\n").unwrap(); let b = build(&mut compiler); assert_eq!(a.len(), b.len()); let lib = dir.0.join("lib.tbl"); fs::write(&lib, &a).unwrap(); fs::write(dir.0.join("main.bas"), "CALL Answer\nEND\n").unwrap(); fs::write(dir.0.join("main.mak"), "main.bas\nlib.tbl\n").unwrap(); let mut app = App::new(&dir.0, dir.0.join("options"), (100, 30)).unwrap(); app.load_initial_project(dir.0.join("main.mak")).unwrap(); let before = app.compile_current().unwrap().to_tbc(); assert!(app.current_compilation().is_some()); let out = dir.0.join("out.tbl"); fs::write(&out, b"old").unwrap(); let (tx, flag) = delayed(&mut app, &out); let staged = prepared(&out); fs::write(&lib, &b).unwrap(); assert!(app.current_compilation().is_none()); let _ = tx.send(Ok(staged)); app.tick_export(); assert!(flag.load(Ordering::Acquire)); assert_eq!(fs::read(&out).unwrap(), b"old"); assert_ne!(before, app.compile_current().unwrap().to_tbc()); no_staging(&dir.0); } }