From 6614e0d19b74e3cea3d37a51cd447ca77c22cf0a Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 16:52:04 -0300 Subject: [PATCH 01/61] refactor(kw): move the kw actor into its own module folder This commit moves src/kw/actor.rs to src/kw/actor/mod.rs unchanged, so its tests can live in a sibling file. Signed-off-by: lorenzoberts --- src/kw/{actor.rs => actor/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/kw/{actor.rs => actor/mod.rs} (100%) diff --git a/src/kw/actor.rs b/src/kw/actor/mod.rs similarity index 100% rename from src/kw/actor.rs rename to src/kw/actor/mod.rs From bc85e554045561a4a7272bcd78814fd1ad324228 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 16:53:04 -0300 Subject: [PATCH 02/61] test(kw): move kw actor tests into actor_test.rs This commit moves the actor's test module out of mod.rs into sibling files. The tests themselves are unchanged; rustfmt rejoins a few lines that fit once the module indent is gone. They are split into actor_test.rs and deploy_test.rs to keep each file under 2,000 lines. Signed-off-by: lorenzoberts --- src/kw/actor/actor_test.rs | 1729 +++++++++++++++++++++++++ src/kw/actor/deploy_test.rs | 691 ++++++++++ src/kw/actor/mod.rs | 2428 +---------------------------------- 3 files changed, 2423 insertions(+), 2425 deletions(-) create mode 100644 src/kw/actor/actor_test.rs create mode 100644 src/kw/actor/deploy_test.rs diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs new file mode 100644 index 0000000..6d59ae2 --- /dev/null +++ b/src/kw/actor/actor_test.rs @@ -0,0 +1,1729 @@ +use std::{ + io, + path::Path, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Mutex, + }, + time::Duration, +}; + +use crate::{ + infrastructure::{ + env::MockEnvTrait, + file_system::{FileSystemError, MockFileSystemTrait}, + process::FakeProcess, + shell::{MockShellTrait, ShellCommand, ShellOutput}, + }, + kw::{ + errors::KwStartError, + history::{KwApplyRecord, KwBuildRecord, MockKwHistoryStore}, + messages::{DeployOptions, StartRequest}, + readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, + remote::RemoteRefusal, + status::{KwJobKind, KwJobStatus, KwPhase}, + }, +}; + +use super::*; + +static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + +/// A real directory: FakeProcess creates the log file on spawn, so the +/// parent must exist even though the fs trait is mocked. +fn tmp_log_dir(test_name: &str) -> PathBuf { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "patch-hub-kw-actor-{}-{test_name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn kernel_tree(path: &Path) -> KernelTree { + serde_json::from_value(serde_json::json!({ + "path": path.to_str().unwrap(), + "branch": "master" + })) + .unwrap() +} + +fn start_request() -> StartRequest { + StartRequest { + kernel_tree_id: "mainline".to_string(), + tree: kernel_tree(Path::new("/home/user/linux")), + branch: "patchset-2026-08-01-17-30-00".to_string(), + extra_args: Vec::new(), + deploy: None, + } +} + +/// Spawns the actor with the already-configured mocks (mockall +/// expectations need `&mut`, so they are set before the mocks move +/// behind `Arc`s). The log dir is a real unique temp dir even though +/// these tests never start a job, so a future test that accidentally +/// does cannot share a fixed path with the job tests. +fn spawn_test_actor( + test_name: &str, + history: MockKwHistoryStore, + shell: MockShellTrait, + fs: MockFileSystemTrait, + env: MockEnvTrait, +) -> KwHandle { + KwActor::spawn( + Arc::new(history), + Arc::new(FakeProcess::new()), + Arc::new(shell), + Arc::new(fs), + Arc::new(env), + tmp_log_dir(test_name), + ) +} + +const KW_VERSION_OK: &[u8] = b"kw, version 0.10.0\n"; +/// A clean `git status --porcelain` answer. +const CLEAN_STATUS: (&[u8], &[u8], bool) = (b"", b"", true); +/// A successful `git switch` answer. +const SWITCH_OK: (&[u8], bool) = (b"", true); + +fn command_parts(cmd: &ShellCommand) -> Vec { + let mut parts = vec![cmd.program.clone()]; + parts.extend(cmd.args.clone()); + parts +} + +fn command(parts: &[&str]) -> Vec { + parts.iter().map(|part| part.to_string()).collect() +} + +/// A shell mock that logs every command's argv parts and answers by +/// content: kw's version probe with `kw_version`; `git status +/// --porcelain` with `status` (stdout, stderr, success); `git switch` +/// with `switch` (stderr, success); any other git call — the HEAD +/// branch probe — with `master`. +fn recording_shell( + kw_version: &'static [u8], + status: (&'static [u8], &'static [u8], bool), + switch: (&'static [u8], bool), +) -> (MockShellTrait, Arc>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + let calls_in_shell = Arc::clone(&calls); + let mut shell = MockShellTrait::new(); + shell.expect_execute().returning(move |cmd| { + calls_in_shell.lock().unwrap().push(command_parts(cmd)); + let output = |stdout: &[u8], stderr: &[u8], success: bool| ShellOutput { + stdout: stdout.to_vec(), + stderr: stderr.to_vec(), + success, + }; + if cmd.program == "kw" { + return Ok(output(kw_version, b"", true)); + } + if cmd.args.iter().any(|arg| arg == "status") { + return Ok(output(status.0, status.1, status.2)); + } + if cmd.args.iter().any(|arg| arg == "switch") { + return Ok(output(b"", switch.0, switch.1)); + } + Ok(output(b"master\n", b"", true)) + }); + (shell, calls) +} + +/// A stateful shell double for the checkout/restore tests: kw's +/// version probe answers 0.10.0, `git status --porcelain` reflects the +/// dirty flag, the HEAD probe reports `head` (with the trailing +/// newline git prints), and a successful `git switch` updates `head` +/// — mirroring a real worktree the actor switches between branches. +/// Switches to `fail_switch_to` fail, so a test can model "going +/// forward works, coming back fails". +pub(super) struct GitStub { + head: Arc>, + dirty: Arc, + fail_switch_to: Arc>>, +} + +impl GitStub { + pub(super) fn on_branch(branch: &str) -> Self { + Self { + head: Arc::new(Mutex::new(branch.to_string())), + dirty: Arc::new(AtomicBool::new(false)), + fail_switch_to: Arc::new(Mutex::new(None)), + } + } + + fn set_dirty(&self, dirty: bool) { + self.dirty.store(dirty, Ordering::Relaxed); + } + + fn fail_switches_to(&self, branch: Option<&str>) { + *self.fail_switch_to.lock().unwrap() = branch.map(str::to_string); + } + + pub(super) fn head(&self) -> String { + self.head.lock().unwrap().clone() + } + + pub(super) fn shell(&self) -> MockShellTrait { + let head = Arc::clone(&self.head); + let dirty = Arc::clone(&self.dirty); + let fail_switch_to = Arc::clone(&self.fail_switch_to); + let mut shell = MockShellTrait::new(); + shell.expect_execute().returning(move |cmd| { + let output = |stdout: &[u8]| ShellOutput { + stdout: stdout.to_vec(), + stderr: Vec::new(), + success: true, + }; + if cmd.program == "kw" { + return Ok(output(KW_VERSION_OK)); + } + if cmd.args.iter().any(|arg| arg == "status") { + let stdout: &[u8] = if dirty.load(Ordering::Relaxed) { + b" M src/main.c\n" + } else { + b"" + }; + return Ok(output(stdout)); + } + if cmd.args.iter().any(|arg| arg == "switch") { + let branch = cmd.args.last().unwrap().clone(); + if fail_switch_to.lock().unwrap().as_deref() == Some(branch.as_str()) { + return Ok(ShellOutput { + stdout: Vec::new(), + stderr: b"error: you need to resolve your current index first\n".to_vec(), + success: false, + }); + } + *head.lock().unwrap() = branch; + return Ok(output(b"")); + } + let current = format!("{}\n", head.lock().unwrap()); + Ok(output(current.as_bytes())) + }); + shell + } +} + +/// fs answers for a ready kernel tree with no active kw env: the +/// kernel-root probes pass, `.config` exists, `.kw/env.current` is +/// absent, `.kw/build.config` is unreadable (arch probes as None), +/// and there is no arch/ dir to glob images from. +fn expect_ready_tree(fs: &mut MockFileSystemTrait) { + fs.expect_is_dir().returning(|_| true); + fs.expect_is_file() + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists().returning(|_| true); + fs.expect_read_to_string().returning(|path| { + read_real_job_log(path).unwrap_or_else(|| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }) + }); + fs.expect_read_dir().returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); +} + +/// Job logs are real files ([`FakeProcess`] creates them and +/// `write_log` appends to them), so fs doubles read `*.log` paths from +/// disk. +fn read_real_job_log(path: &Path) -> Option> { + (path.extension() == Some("log".as_ref())) + .then(|| std::fs::read_to_string(path).map_err(FileSystemError::from)) +} + +/// A ready kernel tree whose log dir can be created. +fn ready_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + expect_ready_tree(&mut fs); + fs.expect_create_dir_all().returning(|_| Ok(())); + fs +} + +/// A ready kernel tree whose build produced an image and a +/// kernelrelease: build.config sets `arch=x86`, `arch/x86/boot/` +/// holds a bzImage, and `include/config/kernel.release` exists. The +/// image's metadata is unreadable, so its mtime falls back to the +/// epoch — still the only, hence newest, candidate. +fn built_tree_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir().returning(|_| true); + fs.expect_is_file() + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists().returning(|_| true); + fs.expect_read_to_string().returning(|path| { + if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_read_dir().returning(|path| { + if path.ends_with("arch/x86/boot") { + Ok(vec![PathBuf::from( + "/home/user/linux/arch/x86/boot/bzImage", + )]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_metadata() + .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); + fs.expect_create_dir_all().returning(|_| Ok(())); + fs +} + +pub(super) const DEPLOY_REMOTE_CONFIG: &str = + "#kw-default=dut\nHost dut\n Hostname box\n Port 22\n User root\n"; +pub(super) const DEPLOY_BOOT_ONCE_OFF: &str = "boot_into_new_kernel_once=no\n"; +pub(super) const DEPLOY_BOOT_ONCE_ON: &str = "boot_into_new_kernel_once=yes\n"; + +pub(super) fn deploy_ready_fs() -> MockFileSystemTrait { + deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, true) +} + +pub(super) fn deploy_fs( + remote_config: &'static str, + deploy_config: &'static str, + has_image: bool, +) -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir().returning(|_| true); + fs.expect_is_file() + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists().returning(|_| true); + fs.expect_read_to_string().returning(move |path| { + if let Some(log) = read_real_job_log(path) { + log + } else if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else if path.ends_with("remote.config") { + Ok(remote_config.to_string()) + } else if path.ends_with("deploy.config") { + Ok(deploy_config.to_string()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_read_dir().returning(move |path| { + if has_image && path.ends_with("arch/x86/boot") { + Ok(vec![PathBuf::from( + "/home/user/linux/arch/x86/boot/bzImage", + )]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_metadata() + .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); + fs.expect_create_dir_all().returning(|_| Ok(())); + fs +} + +pub(super) fn matching_build_record() -> KwBuildRecord { + KwBuildRecord { + kernel_tree_id: "mainline".to_string(), + tree_path: "/home/user/linux".to_string(), + message_id: None, + branch: "patchset-2026-08-01-17-30-00".to_string(), + arch: Some("x86".to_string()), + image_path: Some("/home/user/linux/arch/x86/boot/bzImage".to_string()), + output_dir: None, + kernelrelease: Some("6.17.0".to_string()), + log_path: String::new(), + built_at: "2026-08-01T18:10:00Z".to_string(), + success: true, + } +} + +pub(super) fn deploy_options(acknowledged: bool) -> DeployOptions { + DeployOptions { + reboot: false, + force: true, + boot_once_acknowledged: acknowledged, + } +} + +pub(super) fn deploy_request() -> StartRequest { + let mut request = start_request(); + request.deploy = Some(deploy_options(false)); + request +} + +/// History for a deploy-alone start: answers the record lookup for the +/// requested branch and panics if a deploy writes a build record. +pub(super) fn deploy_history(record: Option) -> MockKwHistoryStore { + let mut history = MockKwHistoryStore::new(); + history + .expect_build_records() + .withf(|kernel_tree_id, branch| { + kernel_tree_id == "mainline" && branch == "patchset-2026-08-01-17-30-00" + }) + .returning(move |_, _| Ok((record.clone(), record.clone()))); + history.expect_record_build().times(0); + history +} + +pub(super) fn env_with_kw() -> MockEnvTrait { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env +} + +pub(super) fn spawn_deploy_actor( + test_name: &str, + history: MockKwHistoryStore, + fs: MockFileSystemTrait, +) -> (KwHandle, Arc, PathBuf) { + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + spawn_full_actor(test_name, history, shell, fs, env_with_kw()) +} + +/// History answers for an actor whose jobs complete: no patchset +/// link, build-record writes accepted and dropped. +pub(super) fn quiet_history() -> MockKwHistoryStore { + let mut history = MockKwHistoryStore::new(); + history + .expect_apply_record_for_branch() + .returning(|_, _| Ok(None)); + history.expect_record_build().returning(|_| Ok(())); + history +} + +/// A history double that captures written build records and answers +/// the patchset-link lookup with `apply_record`. +pub(super) fn recording_history( + apply_record: Option, +) -> (MockKwHistoryStore, Arc>>) { + let builds = Arc::new(Mutex::new(Vec::new())); + let builds_in_store = Arc::clone(&builds); + let mut history = MockKwHistoryStore::new(); + history + .expect_apply_record_for_branch() + .returning(move |_, _| Ok(apply_record.clone())); + history.expect_record_build().returning(move |record| { + builds_in_store.lock().unwrap().push(record); + Ok(()) + }); + (history, builds) +} + +/// Spawns the actor with every dependency explicit, a real temp log +/// dir, and the [`FakeProcess`] exposed so tests drive the "running" +/// process. +pub(super) fn spawn_full_actor( + test_name: &str, + history: MockKwHistoryStore, + shell: MockShellTrait, + fs: MockFileSystemTrait, + env: MockEnvTrait, +) -> (KwHandle, Arc, PathBuf) { + let process = Arc::new(FakeProcess::new()); + let log_dir = tmp_log_dir(test_name); + let handle = KwActor::spawn( + Arc::new(history), + process.clone(), + Arc::new(shell), + Arc::new(fs), + Arc::new(env), + log_dir.clone(), + ); + (handle, process, log_dir) +} + +/// Spawns the actor with a real temp log dir and exposes the +/// [`FakeProcess`] so tests drive the "running" process. The env mock +/// has kw on PATH. +fn spawn_job_actor_with_mocks( + test_name: &str, + shell: MockShellTrait, + fs: MockFileSystemTrait, +) -> (KwHandle, Arc, PathBuf) { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + spawn_full_actor(test_name, quiet_history(), shell, fs, env) +} + +fn spawn_job_actor(test_name: &str) -> (KwHandle, Arc, PathBuf) { + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + spawn_job_actor_with_mocks(test_name, shell, ready_fs()) +} + +fn apply_record() -> KwApplyRecord { + KwApplyRecord { + message_id: "msg-1".to_string(), + kernel_tree_id: "mainline".to_string(), + tree_path: "/home/user/linux".to_string(), + applied_branch: "patchset-2026-08-01-17-30-00".to_string(), + base_branch: "master".to_string(), + applied_at: "2026-08-01T17:30:00Z".to_string(), + } +} + +#[tokio::test] +async fn record_apply_writes_through_history_store() { + let expected = apply_record(); + let mut history = MockKwHistoryStore::new(); + history + .expect_record_apply() + .withf(move |record| *record == expected) + .times(1) + .returning(|_| Ok(())); + let handle = spawn_test_actor( + "record-apply", + history, + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + ); + + handle.record_apply(apply_record()).await.unwrap(); + handle.shutdown().await; +} + +#[tokio::test] +async fn record_apply_surfaces_store_errors() { + let mut history = MockKwHistoryStore::new(); + history + .expect_record_apply() + .times(1) + .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); + let handle = spawn_test_actor( + "record-apply-error", + history, + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + ); + + let err = handle.record_apply(apply_record()).await.unwrap_err(); + + assert!(matches!(err, KwError::History(_))); + handle.shutdown().await; +} + +#[tokio::test] +async fn get_status_reports_idle_before_any_job() { + let handle = spawn_test_actor( + "idle-status", + MockKwHistoryStore::new(), + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + ); + + let snapshot = handle.get_status().await.unwrap(); + + assert_eq!(KwJobStatus::Idle, snapshot.job); + assert_eq!(None, snapshot.restore_branch); + handle.shutdown().await; +} + +#[tokio::test] +async fn watch_status_receiver_sees_current_snapshot() { + let handle = spawn_test_actor( + "watch-idle", + MockKwHistoryStore::new(), + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + ); + + let receiver = handle.watch_status().await.unwrap(); + + assert_eq!(KwJobStatus::Idle, receiver.borrow().job); + handle.shutdown().await; +} + +#[tokio::test] +async fn get_readiness_composes_probes_and_head_branch() { + let tree = kernel_tree(Path::new("/home/user/linux")); + + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "kw") + .returning(|_| false); + env.expect_var() + .returning(|_| Err(std::env::VarError::NotPresent.into())); + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_file().returning(|_| false); + fs.expect_is_dir().returning(|_| false); + fs.expect_read_dir().returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + let mut history = MockKwHistoryStore::new(); + history + .expect_build_records() + .withf(|kernel_tree_id, branch| kernel_tree_id == "mainline" && branch == "for-next") + .times(1) + .returning(|_, _| Ok((None, None))); + let mut shell = MockShellTrait::new(); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] + }) + .times(1) + .returning(|_| { + Ok(ShellOutput { + stdout: b"for-next\n".to_vec(), + stderr: Vec::new(), + success: true, + }) + }); + let handle = spawn_test_actor("readiness", history, shell, fs, env); + + let readiness = handle.get_readiness("mainline", &tree, None).await.unwrap(); + + assert!(!readiness.kw_binary.available); + assert_eq!(TreeReadiness::Missing, readiness.tree); + assert_eq!( + Err(DeployAloneRefusal::TreeNotReady(TreeReadiness::Missing)), + readiness.deploy_alone + ); + assert_eq!(Some("for-next".to_string()), readiness.current_branch); + assert_eq!( + Err(RemoteRefusal::NoRemotesConfigured), + readiness.deploy_remote + ); + assert_eq!(BootOnceState::Unknown, readiness.boot_once); + handle.shutdown().await; +} + +#[tokio::test] +async fn get_readiness_for_branch_looks_up_that_branch_not_head() { + let tree = kernel_tree(Path::new("/home/user/linux")); + + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "kw") + .returning(|_| false); + env.expect_var() + .returning(|_| Err(std::env::VarError::NotPresent.into())); + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_file().returning(|_| false); + fs.expect_is_dir().returning(|_| false); + fs.expect_read_dir().returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + let mut history = MockKwHistoryStore::new(); + history + .expect_build_records() + .withf(|kernel_tree_id, branch| kernel_tree_id == "mainline" && branch == "patchset-x") + .times(1) + .returning(|_, _| Ok((None, None))); + let mut shell = MockShellTrait::new(); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] + }) + .times(1) + .returning(|_| { + Ok(ShellOutput { + stdout: b"master\n".to_vec(), + stderr: Vec::new(), + success: true, + }) + }); + let handle = spawn_test_actor("readiness-for-branch", history, shell, fs, env); + + let readiness = handle + .get_readiness("mainline", &tree, Some("patchset-x")) + .await + .unwrap(); + + assert_eq!(Some("master".to_string()), readiness.current_branch); + assert_eq!(None, readiness.build_record); + handle.shutdown().await; +} + +/// Waits until the status leaves `Idle`/`Running` and returns the +/// terminal status. The receiver may have observed the `Running` +/// transition first, so a single `changed()` is not enough. The timeout +/// backstops against a wedged actor; tests that exercise the grace +/// periods run with paused time instead of waiting them out. +pub(super) async fn wait_for_terminal_status( + watch: &mut watch::Receiver, +) -> KwJobStatus { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let status = watch.borrow().job.clone(); + if !matches!(status, KwJobStatus::Idle | KwJobStatus::Running { .. }) { + return status; + } + watch.changed().await.unwrap(); + } + }) + .await + .expect("status must reach a terminal state") +} + +pub(super) async fn wait_for_running_phase( + watch: &mut watch::Receiver, + phase: KwPhase, +) -> KwJobStatus { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let status = watch.borrow().job.clone(); + if matches!( + status, + KwJobStatus::Running { phase: running, .. } if running == phase + ) { + return status; + } + watch.changed().await.unwrap(); + } + }) + .await + .expect("status must reach the expected running phase") +} + +#[tokio::test] +async fn start_build_replies_immediately_and_runs_in_background() { + let (handle, process, log_dir) = spawn_job_actor("start-immediate"); + + // start_build resolves while the spawned process is still running + // (no finish() was ever signaled). + let result = tokio::time::timeout(Duration::from_secs(1), handle.start_build(start_request())) + .await + .expect("start_build must reply immediately"); + result.unwrap(); + + let spawned = process.spawned(); + assert_eq!(1, spawned.len()); + assert_eq!("kw", spawned[0].program); + assert_eq!(["build"], spawned[0].args.as_slice()); + assert_eq!(Path::new("/home/user/linux"), spawned[0].cwd); + assert!(spawned[0].log_path.starts_with(&log_dir)); + + let snapshot = handle.get_status().await.unwrap(); + assert!( + matches!( + snapshot.job, + KwJobStatus::Running { + kind: KwJobKind::Build, + phase: KwPhase::Building, + .. + } + ), + "unexpected status: {:?}", + snapshot.job + ); + + process.last_child().finish(0); + let mut watch = handle.watch_status().await.unwrap(); + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!( + status, + KwJobStatus::Succeeded { + kind: KwJobKind::Build, + .. + } + ), + "unexpected status: {status:?}" + ); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_merges_extra_args_into_the_spawned_argv() { + let (handle, process, log_dir) = spawn_job_actor("extra-args"); + + let mut request = start_request(); + request.extra_args = [ + "--verbose", + "--alert=vv", + "--save-log-to", + "/tmp/x.log", + "--ccache", + ] + .into_iter() + .map(String::from) + .collect(); + handle.start_build(request).await.unwrap(); + + let spawned = process.spawned(); + // Reserved options are stripped: the user's --alert and --save-log-to + // do not reach kw; the rest passes through in order. + assert_eq!( + ["build", "--verbose", "--ccache"].as_slice(), + spawned[0].args.as_slice() + ); + + process.last_child().finish(0); + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn second_start_while_running_is_refused() { + let (handle, process, log_dir) = spawn_job_actor("busy"); + + handle.start_build(start_request()).await.unwrap(); + let second = handle.start_build(start_request()).await; + + assert!(matches!(second, Err(KwStartError::JobAlreadyRunning))); + + process.last_child().finish(0); + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn failed_build_reports_exit_code_and_log_path() { + let (handle, process, log_dir) = spawn_job_actor("failed"); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().write_log( + b" CC init/main.o\n\ + init/main.c:1691:2: error: #error broken\n\ + make: *** [Makefile:248: __sub-make] Error 2\n", + ); + process.last_child().finish(2); + + let status = wait_for_terminal_status(&mut watch).await; + + match status { + KwJobStatus::Failed { + kind, + phase, + exit_code, + log_path, + first_error, + } => { + assert_eq!(KwJobKind::Build, kind); + assert_eq!(KwPhase::Building, phase); + assert_eq!(Some(2), exit_code); + assert!(log_path.starts_with(&log_dir)); + assert_eq!( + Some("init/main.c:1691:2: error: #error broken"), + first_error.as_deref() + ); + } + other => panic!("expected Failed, got {other:?}"), + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn cancel_kills_process_group_and_reports_cancelled() { + let (handle, process, log_dir) = spawn_job_actor("cancel"); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + // The ack is immediate: process death is observed via the status, + // not the reply. + tokio::time::timeout(Duration::from_secs(1), handle.cancel()) + .await + .expect("cancel must ack immediately") + .unwrap(); + + assert!(process.last_child().was_killed()); + let status = wait_for_terminal_status(&mut watch).await; + match status { + KwJobStatus::Cancelled { + kind, + phase, + log_path, + } => { + assert_eq!(KwJobKind::Build, kind); + assert_eq!(KwPhase::Building, phase); + assert!(log_path.starts_with(&log_dir)); + } + other => panic!("expected Cancelled, got {other:?}"), + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn shutdown_kills_running_job() { + let (handle, process, log_dir) = spawn_job_actor("shutdown-kill"); + + handle.start_build(start_request()).await.unwrap(); + // shutdown() returns only after the kill escalation has completed. + handle.shutdown().await; + + assert!(process.last_child().was_killed()); + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn spawn_failure_refuses_start_and_stays_idle() { + let (handle, process, log_dir) = spawn_job_actor("spawn-fail"); + process.refuse_spawns(true); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::Spawn(_))); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + // A refused start must leave the actor able to accept a later one. + process.refuse_spawns(false); + handle.start_build(start_request()).await.unwrap(); + + process.last_child().finish(0); + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_refused_when_kw_binary_missing() { + let process = Arc::new(FakeProcess::new()); + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| false); + let log_dir = tmp_log_dir("kw-missing"); + let handle = KwActor::spawn( + Arc::new(MockKwHistoryStore::new()), + process.clone(), + // No shell or fs calls are expected: the missing binary + // short-circuits the start before any other probe or spawn. + Arc::new(MockShellTrait::new()), + Arc::new(MockFileSystemTrait::new()), + Arc::new(env), + log_dir.clone(), + ); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::KwBinaryMissing)); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_refused_when_tree_not_ready() { + let process = Arc::new(FakeProcess::new()); + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + let mut shell = MockShellTrait::new(); + shell.expect_execute().returning(|_| { + Ok(ShellOutput { + stdout: b"kw, version 0.10.0\n".to_vec(), + stderr: Vec::new(), + success: true, + }) + }); + // The kernel-root probes pass, but there is no .kw directory: kw + // init was never run in this tree. + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir() + .returning(|path| path.file_name().is_none_or(|name| name != ".kw")); + fs.expect_is_file() + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists().returning(|_| true); + let log_dir = tmp_log_dir("tree-not-ready"); + let handle = KwActor::spawn( + Arc::new(MockKwHistoryStore::new()), + process.clone(), + Arc::new(shell), + Arc::new(fs), + Arc::new(env), + log_dir.clone(), + ); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::TreeNotReady(TreeReadiness::MissingKwDir) + )); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_allowed_when_kw_version_below_floor() { + // kw's shipped VERSION file is stale (`beta-0.9` even at the 0.10 + // tag): a below-floor report warns but never gates the start. + let (shell, _calls) = recording_shell(b"kw, version beta-0.9\n", CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = spawn_job_actor_with_mocks("version-below", shell, ready_fs()); + + handle.start_build(start_request()).await.unwrap(); + assert_eq!(1, process.spawned().len()); + + process.last_child().finish(0); + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_switches_to_requested_branch_before_spawning() { + let (shell, calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = + spawn_job_actor_with_mocks("checkout-order", shell, ready_fs()); + + handle.start_build(start_request()).await.unwrap(); + + { + let calls = calls.lock().unwrap(); + let git_position = |subcommand: &str| { + calls + .iter() + .position(|call| { + call.first().map(String::as_str) == Some("git") + && call.iter().any(|part| part == subcommand) + }) + .expect("expected git call missing") + }; + let status = git_position("status"); + let head = git_position("--show-current"); + let switch = git_position("switch"); + assert!( + status < head && head < switch, + "checkout policy must probe dirty state, then HEAD, then switch: {calls:?}" + ); + // Untracked scratch files don't block a build; only tracked + // changes do. + assert_eq!( + &calls[status], + &command(&[ + "git", + "-C", + "/home/user/linux", + "status", + "--porcelain", + "--untracked-files=no" + ]) + ); + // The `--` keeps a branch named like a flag from being parsed + // as one. + assert_eq!( + &calls[switch], + &command(&[ + "git", + "-C", + "/home/user/linux", + "switch", + "--", + "patchset-2026-08-01-17-30-00" + ]) + ); + } + assert_eq!(1, process.spawned().len()); + + process.last_child().finish(0); + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_refused_when_worktree_is_dirty() { + let (shell, calls) = recording_shell(KW_VERSION_OK, (b" M src/main.c\n", b"", true), SWITCH_OK); + let mut fs = MockFileSystemTrait::new(); + expect_ready_tree(&mut fs); + let (handle, process, log_dir) = spawn_job_actor_with_mocks("dirty", shell, fs); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::DirtyWorktree)); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert!(process.spawned().is_empty()); + // The refusal happens before any branch mutation. + assert!(!calls + .lock() + .unwrap() + .iter() + .any(|call| call.iter().any(|part| part == "switch"))); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_refused_when_git_state_is_unverifiable() { + let (shell, _calls) = recording_shell( + KW_VERSION_OK, + (b"", b"fatal: not a git repository\n", false), + SWITCH_OK, + ); + let mut fs = MockFileSystemTrait::new(); + expect_ready_tree(&mut fs); + let (handle, process, log_dir) = spawn_job_actor_with_mocks("git-probe-fail", shell, fs); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::GitStateProbe(_))); + assert!(err.to_string().contains("not a git repository")); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_refused_when_branch_switch_fails() { + let (shell, _calls) = recording_shell( + KW_VERSION_OK, + CLEAN_STATUS, + ( + b"error: pathspec 'no-such-branch' did not match any file(s) known to git\n", + false, + ), + ); + let (handle, process, log_dir) = spawn_job_actor_with_mocks("switch-fail", shell, ready_fs()); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::CheckoutFailed(_))); + assert!(err.to_string().contains("did not match")); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn cancel_and_restore_without_job_are_immediate_errors() { + let handle = spawn_test_actor( + "no-job", + MockKwHistoryStore::new(), + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + ); + + let cancel = tokio::time::timeout(Duration::from_secs(1), handle.cancel()) + .await + .expect("cancel must reply immediately"); + let restore = tokio::time::timeout(Duration::from_secs(1), handle.restore_previous_branch()) + .await + .expect("restore must reply immediately"); + + assert!(matches!(cancel, Err(KwError::NoJobRunning))); + assert!(matches!(restore, Err(KwError::NoRecordedBranch))); + handle.shutdown().await; +} + +#[tokio::test] +async fn restore_switches_back_to_pre_job_branch_and_is_consumed() { + let git = GitStub::on_branch("master"); + let (handle, process, log_dir) = spawn_job_actor_with_mocks("restore", git.shell(), ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + // The checkout policy left HEAD on the build branch. + assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); + assert_eq!( + Some("master"), + handle.get_status().await.unwrap().restore_branch.as_deref() + ); + process.last_child().finish(0); + let status = wait_for_terminal_status(&mut watch).await; + assert!(matches!(status, KwJobStatus::Succeeded { .. })); + assert_eq!( + Some("master"), + handle.get_status().await.unwrap().restore_branch.as_deref() + ); + + handle.restore_previous_branch().await.unwrap(); + assert_eq!(git.head(), "master"); + assert_eq!(None, handle.get_status().await.unwrap().restore_branch); + + // A successful restore consumes the context: a second restore has + // nothing to do. + let err = handle.restore_previous_branch().await.unwrap_err(); + assert!(matches!(err, KwError::NoRecordedBranch)); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn restore_refused_while_job_is_running() { + let git = GitStub::on_branch("master"); + let (handle, process, log_dir) = + spawn_job_actor_with_mocks("restore-running", git.shell(), ready_fs()); + + handle.start_build(start_request()).await.unwrap(); + let err = handle.restore_previous_branch().await.unwrap_err(); + assert!(matches!(err, KwError::JobRunning)); + + // The context survives the refusal: restore works once the job + // ends. + process.last_child().finish(0); + let mut watch = handle.watch_status().await.unwrap(); + let _ = wait_for_terminal_status(&mut watch).await; + handle.restore_previous_branch().await.unwrap(); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn restore_refused_when_worktree_is_dirty() { + let git = GitStub::on_branch("master"); + let (handle, process, log_dir) = + spawn_job_actor_with_mocks("restore-dirty", git.shell(), ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let _ = wait_for_terminal_status(&mut watch).await; + + git.set_dirty(true); + let err = handle.restore_previous_branch().await.unwrap_err(); + assert!(matches!(err, KwError::DirtyWorktree)); + // The refused restore did not touch the tree. + assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); + assert_eq!( + Some("master"), + handle.get_status().await.unwrap().restore_branch.as_deref() + ); + + // The context survives: clean the tree and retry. + git.set_dirty(false); + handle.restore_previous_branch().await.unwrap(); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn restore_failure_keeps_the_context_for_a_retry() { + let git = GitStub::on_branch("master"); + let (handle, process, log_dir) = + spawn_job_actor_with_mocks("restore-fail", git.shell(), ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let _ = wait_for_terminal_status(&mut watch).await; + + git.fail_switches_to(Some("master")); + let err = handle.restore_previous_branch().await.unwrap_err(); + assert!(matches!(err, KwError::CheckoutFailed(_))); + assert!(err.to_string().contains("resolve your current index")); + + git.fail_switches_to(None); + handle.restore_previous_branch().await.unwrap(); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn refused_start_does_not_clobber_the_restore_context() { + let git = GitStub::on_branch("master"); + let (handle, process, log_dir) = + spawn_job_actor_with_mocks("restore-clobber", git.shell(), ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let _ = wait_for_terminal_status(&mut watch).await; + assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); + + // This start is refused at spawn — after its HEAD probe and + // switch — and the switch is rolled back: the tree returns to the + // first job's branch and the recorded restore target is intact. + process.refuse_spawns(true); + let mut second = start_request(); + second.branch = "patchset-two".to_string(); + let err = handle.start_build(second).await.unwrap_err(); + assert!(matches!(err, KwStartError::Spawn(_))); + assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); + + handle.restore_previous_branch().await.unwrap(); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn shutdown_stops_actor() { + let handle = spawn_test_actor( + "shutdown", + MockKwHistoryStore::new(), + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + ); + + handle.shutdown().await; + let err = handle.get_status().await.unwrap_err(); + + assert!(matches!(err, KwError::ActorUnavailable(_))); +} + +#[tokio::test] +async fn log_dir_creation_failure_refuses_start_and_stays_idle() { + let git = GitStub::on_branch("master"); + let mut fs = MockFileSystemTrait::new(); + expect_ready_tree(&mut fs); + fs.expect_create_dir_all().returning(|_| { + Err(FileSystemError::IoError(io::Error::other( + "read-only filesystem", + ))) + }); + let (handle, process, log_dir) = spawn_job_actor_with_mocks("log-dir-fail", git.shell(), fs); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::Fs(_))); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert!(process.spawned().is_empty()); + // The switch happened and was rolled back: the tree is back on the + // user's branch, and no restore target was recorded. + assert_eq!(git.head(), "master"); + assert!(matches!( + handle.restore_previous_branch().await, + Err(KwError::NoRecordedBranch) + )); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn rollback_failure_keeps_the_spawn_refusal() { + let git = GitStub::on_branch("master"); + // Going forward works; coming back fails. + git.fail_switches_to(Some("master")); + let (handle, process, log_dir) = + spawn_job_actor_with_mocks("rollback-fails", git.shell(), ready_fs()); + process.refuse_spawns(true); + + let err = handle.start_build(start_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::Spawn(_))); + // The rollback failure is logged, not reported: the caller keeps + // the actionable refusal, and the tree honestly shows where HEAD + // is. No restore target was recorded. + assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); + assert!(matches!( + handle.restore_previous_branch().await, + Err(KwError::NoRecordedBranch) + )); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn cancel_racing_a_successful_exit_reports_success() { + let (handle, process, log_dir) = spawn_job_actor("cancel-race"); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + // Whether the actor processes the exit or the cancel first is + // timing-dependent, but the terminal status must be Succeeded + // either way: the process exited before any signal landed. + let _ = handle.cancel().await; + + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!(status, KwJobStatus::Succeeded { .. }), + "expected Succeeded, got {status:?}" + ); + // Killing an already-finished process is a no-op, not a kill. + assert!(!process.last_child().was_killed()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn cancel_racing_a_failed_exit_reports_failure() { + let (handle, process, log_dir) = spawn_job_actor("cancel-race-fail"); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(2); + // Whether the actor processes the exit or the cancel first is + // timing-dependent, but a plain exit (not signal-terminated) means + // the process failed on its own before the signal landed: the + // terminal status must be Failed either way, so the build history + // records a failure rather than a cancel. + let _ = handle.cancel().await; + + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!( + status, + KwJobStatus::Failed { + exit_code: Some(2), + .. + } + ), + "expected Failed(2), got {status:?}" + ); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +// Paused time: the runtime auto-advances through the grace-period +// timers instead of burning wall-clock seconds on them. +#[tokio::test(start_paused = true)] +async fn cancel_escalates_to_sigkill_when_sigterm_is_ignored() { + let (handle, process, log_dir) = spawn_job_actor("sigkill-escalation"); + process.ignore_sigterm(true); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + handle.cancel().await.unwrap(); + + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!(status, KwJobStatus::Cancelled { .. }), + "expected Cancelled, got {status:?}" + ); + let child = process.last_child(); + assert!(child.was_killed()); + assert!(child.was_force_killed()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn successful_build_writes_a_full_build_record() { + let (history, builds) = recording_history(Some(apply_record())); + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = + spawn_full_actor("build-record", history, shell, built_tree_fs(), { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let status = wait_for_terminal_status(&mut watch).await; + assert!(matches!(status, KwJobStatus::Succeeded { .. })); + + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + let record = &builds[0]; + assert_eq!("mainline", record.kernel_tree_id); + assert_eq!("/home/user/linux", record.tree_path); + assert_eq!(Some("msg-1"), record.message_id.as_deref()); + assert_eq!("patchset-2026-08-01-17-30-00", record.branch); + assert_eq!(Some("x86"), record.arch.as_deref()); + assert_eq!( + Some("/home/user/linux/arch/x86/boot/bzImage"), + record.image_path.as_deref() + ); + assert_eq!(None, record.output_dir); + assert_eq!(Some("6.17.0"), record.kernelrelease.as_deref()); + assert!(record.log_path.starts_with(log_dir.to_str().unwrap())); + assert!(record.success); + // The readiness latest-lookup parses built_at as RFC3339. + assert!(chrono::DateTime::parse_from_rfc3339(&record.built_at).is_ok()); + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn failed_build_writes_a_failure_record() { + let (history, builds) = recording_history(Some(apply_record())); + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = + spawn_full_actor("failed-record", history, shell, built_tree_fs(), { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(2); + let status = wait_for_terminal_status(&mut watch).await; + assert!(matches!( + status, + KwJobStatus::Failed { + exit_code: Some(2), + .. + } + )); + + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + let record = &builds[0]; + assert!(!record.success); + // Config/env facts are still recorded; what the build never + // produced is not — a stale image from an earlier build must + // not leak into a failure record. + assert_eq!(Some("x86"), record.arch.as_deref()); + assert_eq!(None, record.image_path); + assert_eq!(None, record.kernelrelease); + assert_eq!(Some("msg-1"), record.message_id.as_deref()); + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn cancelled_build_writes_no_record() { + let (history, builds) = recording_history(None); + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, _process, log_dir) = + spawn_full_actor("cancel-record", history, shell, ready_fs(), { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + handle.cancel().await.unwrap(); + let status = wait_for_terminal_status(&mut watch).await; + + assert!(matches!(status, KwJobStatus::Cancelled { .. })); + assert!(builds.lock().unwrap().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn lost_exit_status_records_a_failed_build() { + let (history, builds) = recording_history(None); + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = + spawn_full_actor("wait-failure", history, shell, ready_fs(), { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.unwrap(); + process.fail_waits(true); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let status = wait_for_terminal_status(&mut watch).await; + + // A lost exit status is an honest failure: the record must not + // become deploy-alone evidence. + assert!(matches!( + status, + KwJobStatus::Failed { + exit_code: None, + .. + } + )); + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + assert!(!builds[0].success); + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn build_record_write_failure_keeps_the_terminal_status() { + let mut history = MockKwHistoryStore::new(); + history + .expect_apply_record_for_branch() + .returning(|_, _| Ok(None)); + history + .expect_record_build() + .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = + spawn_full_actor("record-write-fails", history, shell, ready_fs(), { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let status = wait_for_terminal_status(&mut watch).await; + + // The build's real outcome reached the user; a history-write + // failure must not turn it into a reported failure. + assert!(matches!(status, KwJobStatus::Succeeded { .. })); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn build_record_keeps_no_patchset_link_when_apply_lookup_fails() { + let builds = Arc::new(Mutex::new(Vec::new())); + let builds_in_store = Arc::clone(&builds); + let mut history = MockKwHistoryStore::new(); + history.expect_apply_record_for_branch().returning(|_, _| { + Err(FileSystemError::IoError(io::Error::other( + "corrupt history", + ))) + }); + history.expect_record_build().returning(move |record| { + builds_in_store.lock().unwrap().push(record); + Ok(()) + }); + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = + spawn_full_actor("link-lookup-fails", history, shell, ready_fs(), { + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let _ = wait_for_terminal_status(&mut watch).await; + + // The lookup error must not drop the record, only the link. + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + assert_eq!(None, builds[0].message_id); + assert!(builds[0].success); + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn successful_build_with_active_env_records_the_output_dir() { + // env.current is read once, at accept time. The completion record + // must describe the env the build ran under — the snapshot — not + // the tree's env state at whatever time the job ends. + let env_current_reads = Arc::new(AtomicU64::new(0)); + let env_current_reads_in_fs = Arc::clone(&env_current_reads); + let mut fs = MockFileSystemTrait::new(); + // With an env active the build artifacts live only under O=; the + // source tree is clean. + fs.expect_is_dir() + .returning(|path| path != Path::new("/home/user/linux/include/config")); + fs.expect_is_file() + .returning(|path| path != Path::new("/home/user/linux/.config")); + fs.expect_exists().returning(|_| true); + fs.expect_read_to_string().returning(move |path| { + if path.ends_with("env.current") { + env_current_reads_in_fs.fetch_add(1, Ordering::SeqCst); + Ok("testenv\n".to_string()) + } else if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + // Every boot-dir probe answers with an image inside the probed + // dir: the record's image path shows which build root was used. + fs.expect_read_dir().returning(|path| { + if path.ends_with("arch/x86/boot") { + Ok(vec![path.join("bzImage")]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_metadata() + .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); + fs.expect_create_dir_all().returning(|_| Ok(())); + let mut env = MockEnvTrait::new(); + env.expect_which().returning(|_| true); + env.expect_var() + .returning(|_| Ok("/home/user/.cache".to_string())); + let (history, builds) = recording_history(None); + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + let (handle, process, log_dir) = spawn_full_actor("env-build", history, shell, fs, env); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_build(start_request()).await.unwrap(); + process.last_child().finish(0); + let status = wait_for_terminal_status(&mut watch).await; + assert!(matches!(status, KwJobStatus::Succeeded { .. })); + + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + let record = &builds[0]; + let output_dir = record + .output_dir + .as_deref() + .expect("an env build records its O= dir"); + assert!( + output_dir.contains("/kw/envs/") && output_dir.ends_with("/testenv"), + "unexpected output dir: {output_dir}" + ); + let image = record.image_path.as_deref().expect("image recorded"); + assert!( + image.starts_with(output_dir) && image.ends_with("bzImage"), + "image {image} must be probed under the env's output dir {output_dir}" + ); + assert_eq!(Some("x86"), record.arch.as_deref()); + assert_eq!(Some("6.17.0"), record.kernelrelease.as_deref()); + assert!(record.success); + } + // Read at accept time only: the completion record uses the + // snapshot, never a re-resolve. + assert_eq!(1, env_current_reads.load(Ordering::SeqCst)); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} diff --git a/src/kw/actor/deploy_test.rs b/src/kw/actor/deploy_test.rs new file mode 100644 index 0000000..4c7b007 --- /dev/null +++ b/src/kw/actor/deploy_test.rs @@ -0,0 +1,691 @@ +use crate::kw::{ + history::MockKwHistoryStore, readiness::DeployAloneRefusal, remote::RemoteRefusal, +}; + +use super::{actor_test::*, *}; + +#[tokio::test] +async fn start_deploy_replies_immediately_and_runs_in_background() { + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-immediate", + deploy_history(Some(matching_build_record())), + deploy_ready_fs(), + ); + + let result = tokio::time::timeout( + Duration::from_secs(1), + handle.start_deploy(deploy_request()), + ) + .await + .expect("start_deploy must reply immediately"); + result.unwrap(); + + let spawned = process.spawned(); + assert_eq!(1, spawned.len()); + assert_eq!("kw", spawned[0].program); + assert_eq!( + [ + "deploy", + "--remote", + "root@box:22", + "--no-reboot", + "--force" + ] + .as_slice(), + spawned[0].args.as_slice() + ); + assert_eq!(Path::new("/home/user/linux"), spawned[0].cwd); + assert!(spawned[0].log_path.starts_with(&log_dir)); + assert!(spawned[0] + .log_path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("deploy-")); + + let snapshot = handle.get_status().await.unwrap(); + assert!( + matches!( + snapshot.job, + KwJobStatus::Running { + kind: KwJobKind::Deploy, + phase: KwPhase::Deploying, + .. + } + ), + "unexpected status: {:?}", + snapshot.job + ); + + process.last_child().finish(0); + let mut watch = handle.watch_status().await.unwrap(); + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!( + status, + KwJobStatus::Succeeded { + kind: KwJobKind::Deploy, + .. + } + ), + "unexpected status: {status:?}" + ); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn deploy_exit_zero_with_initramfs_failure_succeeds_with_warnings() { + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-warnings", + deploy_history(Some(matching_build_record())), + deploy_ready_fs(), + ); + let mut watch = handle.watch_status().await.unwrap(); + + handle.start_deploy(deploy_request()).await.unwrap(); + process.last_child().write_log( + b"update-initramfs: Generating /boot/initrd.img-6.17.0\n\ + E: gzip compression (CONFIG_RD_GZIP) not supported by kernel\n\ + update-initramfs: failed for /boot/initrd.img-6.17.0 with 1.\n\ + Generating grub configuration file ...\n\ + Found linux image: /boot/vmlinuz-6.8.0-generic\n\ + done\n", + ); + process.last_child().finish(0); + + match wait_for_terminal_status(&mut watch).await { + KwJobStatus::Succeeded { kind, warnings, .. } => { + assert_eq!(KwJobKind::Deploy, kind); + assert_eq!( + vec![ + "update-initramfs: failed for /boot/initrd.img-6.17.0 with 1.".to_string(), + "GRUB did not list kernel 6.17.0".to_string(), + ], + warnings + ); + } + other => panic!("expected Succeeded, got {other:?}"), + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn build_then_deploy_checks_grub_for_the_release_it_just_built() { + let (handle, process, log_dir) = + spawn_deploy_actor("chain-warnings", quiet_history(), deploy_ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle + .start_build_then_deploy(deploy_request()) + .await + .unwrap(); + process.last_child().finish(0); + wait_for_running_phase(&mut watch, KwPhase::Deploying).await; + process.last_child().write_log( + b"Generating grub configuration file ...\n\ + Found linux image: /boot/vmlinuz-6.8.0-generic\n\ + Found linux image: /boot/Image-6.17.0-rc1\n\ + done\n", + ); + process.last_child().finish(0); + + match wait_for_terminal_status(&mut watch).await { + KwJobStatus::Succeeded { warnings, .. } => { + assert_eq!( + vec!["GRUB did not list kernel 6.17.0".to_string()], + warnings + ); + } + other => panic!("expected Succeeded, got {other:?}"), + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_merges_extras_and_follows_reboot_force_options() { + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-argv", + deploy_history(Some(matching_build_record())), + deploy_ready_fs(), + ); + + let mut request = deploy_request(); + request.deploy = Some(DeployOptions { + reboot: true, + force: false, + boot_once_acknowledged: false, + }); + request.extra_args = [ + "--verbose", + "--local", + "--alert=n", + "--force", + "--remote", + "other:22", + ] + .into_iter() + .map(String::from) + .collect(); + handle.start_deploy(request).await.unwrap(); + + let spawned = process.spawned(); + assert_eq!( + ["deploy", "--remote", "root@box:22", "--reboot", "--verbose"].as_slice(), + spawned[0].args.as_slice() + ); + + process.last_child().finish(0); + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_without_a_build_record() { + let (handle, process, log_dir) = + spawn_deploy_actor("deploy-no-record", deploy_history(None), deploy_ready_fs()); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::NoBuildRecord) + )); + assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_when_last_build_failed() { + let mut record = matching_build_record(); + record.success = false; + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-failed-build", + deploy_history(Some(record)), + deploy_ready_fs(), + ); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::LastBuildFailed) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_on_tree_path_drift() { + let mut record = matching_build_record(); + record.tree_path = "/other/linux".to_string(); + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-drift", + deploy_history(Some(record)), + deploy_ready_fs(), + ); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::TreePathDrift { .. }) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_on_output_dir_mismatch() { + let mut record = matching_build_record(); + record.output_dir = Some("/cache/kw/envs/testenv".to_string()); + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-env", + deploy_history(Some(record)), + deploy_ready_fs(), + ); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::OutputDirMismatch) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_when_image_is_missing() { + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-no-image", + deploy_history(Some(matching_build_record())), + deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, false), + ); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::ImageMissing) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_when_remote_is_unresolved() { + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-no-remote", + MockKwHistoryStore::new(), + deploy_fs("", DEPLOY_BOOT_ONCE_OFF, true), + ); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::RemoteUnresolved(RemoteRefusal::NoRemotesConfigured) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_when_boot_once_is_on_and_unacked() { + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-boot-once", + deploy_history(Some(matching_build_record())), + deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_ON, true), + ); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!(err, KwStartError::BootOnceNotAcknowledged)); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_refused_when_latest_build_is_on_another_branch() { + let mut latest = matching_build_record(); + latest.branch = "other".to_string(); + let mut history = MockKwHistoryStore::new(); + history + .expect_build_records() + .returning(move |_, _| Ok((None, Some(latest.clone())))); + history.expect_record_build().times(0); + let (handle, process, log_dir) = + spawn_deploy_actor("deploy-head-mismatch", history, deploy_ready_fs()); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::HeadMismatch { .. }) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_proceeds_when_boot_once_is_on_and_acked() { + let (handle, process, log_dir) = spawn_deploy_actor( + "deploy-boot-once-acked", + deploy_history(Some(matching_build_record())), + deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_ON, true), + ); + + let mut request = deploy_request(); + request.deploy = Some(deploy_options(true)); + handle.start_deploy(request).await.unwrap(); + assert_eq!(1, process.spawned().len()); + + process.last_child().finish(0); + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_deploy_post_switch_refusal_rolls_the_switch_back() { + let git = GitStub::on_branch("master"); + let (handle, process, log_dir) = spawn_full_actor( + "deploy-rollback", + deploy_history(None), + git.shell(), + deploy_ready_fs(), + env_with_kw(), + ); + + let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::NoBuildRecord) + )); + assert!(process.spawned().is_empty()); + assert_eq!(git.head(), "master"); + assert!(matches!( + handle.restore_previous_branch().await, + Err(KwError::NoRecordedBranch) + )); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { + let (history, builds) = recording_history(None); + let (handle, process, log_dir) = + spawn_deploy_actor("chain-success", history, deploy_ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + let mut request = deploy_request(); + request.extra_args = ["--verbose", "--ccache", "--alert=n"] + .into_iter() + .map(String::from) + .collect(); + tokio::time::timeout( + Duration::from_secs(1), + handle.start_build_then_deploy(request), + ) + .await + .expect("start_build_then_deploy must reply immediately") + .unwrap(); + + assert_eq!(1, process.spawned().len()); + assert_eq!( + ["build", "--verbose", "--ccache"].as_slice(), + process.spawned()[0].args.as_slice() + ); + assert!( + matches!( + handle.get_status().await.unwrap().job, + KwJobStatus::Running { + kind: KwJobKind::BuildThenDeploy, + phase: KwPhase::Building, + .. + } + ), + "unexpected status: {:?}", + handle.get_status().await.unwrap().job + ); + + let build = process.last_child(); + build.finish(0); + let deploying = wait_for_running_phase(&mut watch, KwPhase::Deploying).await; + match deploying { + KwJobStatus::Running { + kind, + phase, + log_path, + .. + } => { + assert_eq!(KwJobKind::BuildThenDeploy, kind); + assert_eq!(KwPhase::Deploying, phase); + assert!(log_path + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("deploy-")); + } + other => panic!("expected Running Deploying, got {other:?}"), + } + + let spawned = process.spawned(); + assert_eq!(2, spawned.len()); + assert_eq!( + [ + "deploy", + "--remote", + "root@box:22", + "--no-reboot", + "--force", + "--verbose", + ] + .as_slice(), + spawned[1].args.as_slice() + ); + assert_eq!(Path::new("/home/user/linux"), spawned[1].cwd); + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + assert!(builds[0].success); + } + + process.last_child().finish(0); + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!( + status, + KwJobStatus::Succeeded { + kind: KwJobKind::BuildThenDeploy, + .. + } + ), + "unexpected status: {status:?}" + ); + assert_eq!(1, builds.lock().unwrap().len()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_then_deploy_skips_deploy_when_the_build_fails() { + let (history, builds) = recording_history(None); + let (handle, process, log_dir) = + spawn_deploy_actor("chain-build-fail", history, deploy_ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle + .start_build_then_deploy(deploy_request()) + .await + .unwrap(); + process.last_child().finish(2); + + let status = wait_for_terminal_status(&mut watch).await; + match status { + KwJobStatus::Failed { + kind, + phase, + exit_code, + .. + } => { + assert_eq!(KwJobKind::BuildThenDeploy, kind); + assert_eq!(KwPhase::Building, phase); + assert_eq!(Some(2), exit_code); + } + other => panic!("expected Failed Building, got {other:?}"), + } + assert_eq!(1, process.spawned().len()); + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + assert!(!builds[0].success); + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_then_deploy_cancel_in_building_skips_deploy_and_record() { + let (history, builds) = recording_history(None); + let (handle, process, log_dir) = + spawn_deploy_actor("chain-cancel-build", history, deploy_ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle + .start_build_then_deploy(deploy_request()) + .await + .unwrap(); + handle.cancel().await.unwrap(); + + let status = wait_for_terminal_status(&mut watch).await; + match status { + KwJobStatus::Cancelled { kind, phase, .. } => { + assert_eq!(KwJobKind::BuildThenDeploy, kind); + assert_eq!(KwPhase::Building, phase); + } + other => panic!("expected Cancelled Building, got {other:?}"), + } + assert_eq!(1, process.spawned().len()); + assert!(process.last_child().was_killed()); + assert!(builds.lock().unwrap().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_then_deploy_cancel_then_exit_zero_skips_deploy() { + let (history, builds) = recording_history(None); + let (handle, process, log_dir) = + spawn_deploy_actor("chain-cancel-exit-zero", history, deploy_ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + process.ignore_sigterm(true); + handle + .start_build_then_deploy(deploy_request()) + .await + .unwrap(); + handle.cancel().await.unwrap(); + process.last_child().finish(0); + + let status = wait_for_terminal_status(&mut watch).await; + match status { + KwJobStatus::Cancelled { kind, phase, .. } => { + assert_eq!(KwJobKind::BuildThenDeploy, kind); + assert_eq!(KwPhase::Building, phase); + } + other => panic!("expected Cancelled Building, got {other:?}"), + } + assert_eq!(1, process.spawned().len()); + assert!(builds.lock().unwrap().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_then_deploy_cancel_in_deploying_keeps_the_build_record() { + let (history, builds) = recording_history(None); + let (handle, process, log_dir) = + spawn_deploy_actor("chain-cancel-deploy", history, deploy_ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle + .start_build_then_deploy(deploy_request()) + .await + .unwrap(); + process.last_child().finish(0); + let _ = wait_for_running_phase(&mut watch, KwPhase::Deploying).await; + assert_eq!(1, builds.lock().unwrap().len()); + + handle.cancel().await.unwrap(); + let status = wait_for_terminal_status(&mut watch).await; + match status { + KwJobStatus::Cancelled { kind, phase, .. } => { + assert_eq!(KwJobKind::BuildThenDeploy, kind); + assert_eq!(KwPhase::Deploying, phase); + } + other => panic!("expected Cancelled Deploying, got {other:?}"), + } + assert_eq!(2, process.spawned().len()); + assert!(process.last_child().was_killed()); + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + assert!(builds[0].success); + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_then_deploy_spawn_failure_at_boundary_keeps_the_build_record() { + let (history, builds) = recording_history(None); + let (handle, process, log_dir) = + spawn_deploy_actor("chain-spawn-fail", history, deploy_ready_fs()); + let mut watch = handle.watch_status().await.unwrap(); + + handle + .start_build_then_deploy(deploy_request()) + .await + .unwrap(); + let build = process.last_child(); + process.refuse_spawns(true); + build.finish(0); + + let status = wait_for_terminal_status(&mut watch).await; + match status { + KwJobStatus::Failed { + kind, + phase, + exit_code, + .. + } => { + assert_eq!(KwJobKind::BuildThenDeploy, kind); + assert_eq!(KwPhase::Deploying, phase); + assert_eq!(None, exit_code); + } + other => panic!("expected Failed Deploying with no exit, got {other:?}"), + } + assert_eq!(1, process.spawned().len()); + { + let builds = builds.lock().unwrap(); + assert_eq!(1, builds.len()); + assert!(builds[0].success); + } + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} + +#[tokio::test] +async fn start_build_then_deploy_refused_when_remote_is_unresolved() { + let (handle, process, log_dir) = spawn_deploy_actor( + "chain-no-remote", + MockKwHistoryStore::new(), + deploy_fs("", DEPLOY_BOOT_ONCE_OFF, true), + ); + + let err = handle + .start_build_then_deploy(deploy_request()) + .await + .unwrap_err(); + + assert!(matches!( + err, + KwStartError::RemoteUnresolved(RemoteRefusal::NoRemotesConfigured) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + std::fs::remove_dir_all(&log_dir).unwrap(); +} diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index e2bfa2b..734a9da 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -1225,2428 +1225,6 @@ fn send_value_reply(message_name: &'static str, reply: oneshot::Sender, va } #[cfg(test)] -mod tests { - use std::{ - io, - path::Path, - sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, - Mutex, - }, - time::Duration, - }; - - use crate::{ - infrastructure::{ - env::MockEnvTrait, - file_system::{FileSystemError, MockFileSystemTrait}, - process::FakeProcess, - shell::{MockShellTrait, ShellCommand, ShellOutput}, - }, - kw::{ - errors::KwStartError, - history::{KwApplyRecord, KwBuildRecord, MockKwHistoryStore}, - messages::{DeployOptions, StartRequest}, - readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, - remote::RemoteRefusal, - status::{KwJobKind, KwJobStatus, KwPhase}, - }, - }; - - use super::*; - - static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - - /// A real directory: FakeProcess creates the log file on spawn, so the - /// parent must exist even though the fs trait is mocked. - fn tmp_log_dir(test_name: &str) -> PathBuf { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!( - "patch-hub-kw-actor-{}-{test_name}-{n}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - fn kernel_tree(path: &Path) -> KernelTree { - serde_json::from_value(serde_json::json!({ - "path": path.to_str().unwrap(), - "branch": "master" - })) - .unwrap() - } - - fn start_request() -> StartRequest { - StartRequest { - kernel_tree_id: "mainline".to_string(), - tree: kernel_tree(Path::new("/home/user/linux")), - branch: "patchset-2026-08-01-17-30-00".to_string(), - extra_args: Vec::new(), - deploy: None, - } - } - - /// Spawns the actor with the already-configured mocks (mockall - /// expectations need `&mut`, so they are set before the mocks move - /// behind `Arc`s). The log dir is a real unique temp dir even though - /// these tests never start a job, so a future test that accidentally - /// does cannot share a fixed path with the job tests. - fn spawn_test_actor( - test_name: &str, - history: MockKwHistoryStore, - shell: MockShellTrait, - fs: MockFileSystemTrait, - env: MockEnvTrait, - ) -> KwHandle { - KwActor::spawn( - Arc::new(history), - Arc::new(FakeProcess::new()), - Arc::new(shell), - Arc::new(fs), - Arc::new(env), - tmp_log_dir(test_name), - ) - } - - const KW_VERSION_OK: &[u8] = b"kw, version 0.10.0\n"; - /// A clean `git status --porcelain` answer. - const CLEAN_STATUS: (&[u8], &[u8], bool) = (b"", b"", true); - /// A successful `git switch` answer. - const SWITCH_OK: (&[u8], bool) = (b"", true); - - fn command_parts(cmd: &ShellCommand) -> Vec { - let mut parts = vec![cmd.program.clone()]; - parts.extend(cmd.args.clone()); - parts - } - - fn command(parts: &[&str]) -> Vec { - parts.iter().map(|part| part.to_string()).collect() - } - - /// A shell mock that logs every command's argv parts and answers by - /// content: kw's version probe with `kw_version`; `git status - /// --porcelain` with `status` (stdout, stderr, success); `git switch` - /// with `switch` (stderr, success); any other git call — the HEAD - /// branch probe — with `master`. - fn recording_shell( - kw_version: &'static [u8], - status: (&'static [u8], &'static [u8], bool), - switch: (&'static [u8], bool), - ) -> (MockShellTrait, Arc>>>) { - let calls = Arc::new(Mutex::new(Vec::new())); - let calls_in_shell = Arc::clone(&calls); - let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(move |cmd| { - calls_in_shell.lock().unwrap().push(command_parts(cmd)); - let output = |stdout: &[u8], stderr: &[u8], success: bool| ShellOutput { - stdout: stdout.to_vec(), - stderr: stderr.to_vec(), - success, - }; - if cmd.program == "kw" { - return Ok(output(kw_version, b"", true)); - } - if cmd.args.iter().any(|arg| arg == "status") { - return Ok(output(status.0, status.1, status.2)); - } - if cmd.args.iter().any(|arg| arg == "switch") { - return Ok(output(b"", switch.0, switch.1)); - } - Ok(output(b"master\n", b"", true)) - }); - (shell, calls) - } - - /// A stateful shell double for the checkout/restore tests: kw's - /// version probe answers 0.10.0, `git status --porcelain` reflects the - /// dirty flag, the HEAD probe reports `head` (with the trailing - /// newline git prints), and a successful `git switch` updates `head` - /// — mirroring a real worktree the actor switches between branches. - /// Switches to `fail_switch_to` fail, so a test can model "going - /// forward works, coming back fails". - struct GitStub { - head: Arc>, - dirty: Arc, - fail_switch_to: Arc>>, - } - - impl GitStub { - fn on_branch(branch: &str) -> Self { - Self { - head: Arc::new(Mutex::new(branch.to_string())), - dirty: Arc::new(AtomicBool::new(false)), - fail_switch_to: Arc::new(Mutex::new(None)), - } - } - - fn set_dirty(&self, dirty: bool) { - self.dirty.store(dirty, Ordering::Relaxed); - } - - fn fail_switches_to(&self, branch: Option<&str>) { - *self.fail_switch_to.lock().unwrap() = branch.map(str::to_string); - } - - fn head(&self) -> String { - self.head.lock().unwrap().clone() - } - - fn shell(&self) -> MockShellTrait { - let head = Arc::clone(&self.head); - let dirty = Arc::clone(&self.dirty); - let fail_switch_to = Arc::clone(&self.fail_switch_to); - let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(move |cmd| { - let output = |stdout: &[u8]| ShellOutput { - stdout: stdout.to_vec(), - stderr: Vec::new(), - success: true, - }; - if cmd.program == "kw" { - return Ok(output(KW_VERSION_OK)); - } - if cmd.args.iter().any(|arg| arg == "status") { - let stdout: &[u8] = if dirty.load(Ordering::Relaxed) { - b" M src/main.c\n" - } else { - b"" - }; - return Ok(output(stdout)); - } - if cmd.args.iter().any(|arg| arg == "switch") { - let branch = cmd.args.last().unwrap().clone(); - if fail_switch_to.lock().unwrap().as_deref() == Some(branch.as_str()) { - return Ok(ShellOutput { - stdout: Vec::new(), - stderr: b"error: you need to resolve your current index first\n" - .to_vec(), - success: false, - }); - } - *head.lock().unwrap() = branch; - return Ok(output(b"")); - } - let current = format!("{}\n", head.lock().unwrap()); - Ok(output(current.as_bytes())) - }); - shell - } - } - - /// fs answers for a ready kernel tree with no active kw env: the - /// kernel-root probes pass, `.config` exists, `.kw/env.current` is - /// absent, `.kw/build.config` is unreadable (arch probes as None), - /// and there is no arch/ dir to glob images from. - fn expect_ready_tree(fs: &mut MockFileSystemTrait) { - fs.expect_is_dir().returning(|_| true); - fs.expect_is_file() - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(|path| { - read_real_job_log(path).unwrap_or_else(|| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - } - - /// Job logs are real files ([`FakeProcess`] creates them and - /// `write_log` appends to them), so fs doubles read `*.log` paths from - /// disk. - fn read_real_job_log(path: &Path) -> Option> { - (path.extension() == Some("log".as_ref())) - .then(|| std::fs::read_to_string(path).map_err(FileSystemError::from)) - } - - /// A ready kernel tree whose log dir can be created. - fn ready_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - expect_ready_tree(&mut fs); - fs.expect_create_dir_all().returning(|_| Ok(())); - fs - } - - /// A ready kernel tree whose build produced an image and a - /// kernelrelease: build.config sets `arch=x86`, `arch/x86/boot/` - /// holds a bzImage, and `include/config/kernel.release` exists. The - /// image's metadata is unreadable, so its mtime falls back to the - /// epoch — still the only, hence newest, candidate. - fn built_tree_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); - fs.expect_is_file() - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(|path| { - if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_read_dir().returning(|path| { - if path.ends_with("arch/x86/boot") { - Ok(vec![PathBuf::from( - "/home/user/linux/arch/x86/boot/bzImage", - )]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_metadata() - .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all().returning(|_| Ok(())); - fs - } - - const DEPLOY_REMOTE_CONFIG: &str = - "#kw-default=dut\nHost dut\n Hostname box\n Port 22\n User root\n"; - const DEPLOY_BOOT_ONCE_OFF: &str = "boot_into_new_kernel_once=no\n"; - const DEPLOY_BOOT_ONCE_ON: &str = "boot_into_new_kernel_once=yes\n"; - - fn deploy_ready_fs() -> MockFileSystemTrait { - deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, true) - } - - fn deploy_fs( - remote_config: &'static str, - deploy_config: &'static str, - has_image: bool, - ) -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); - fs.expect_is_file() - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(move |path| { - if let Some(log) = read_real_job_log(path) { - log - } else if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else if path.ends_with("remote.config") { - Ok(remote_config.to_string()) - } else if path.ends_with("deploy.config") { - Ok(deploy_config.to_string()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_read_dir().returning(move |path| { - if has_image && path.ends_with("arch/x86/boot") { - Ok(vec![PathBuf::from( - "/home/user/linux/arch/x86/boot/bzImage", - )]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_metadata() - .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all().returning(|_| Ok(())); - fs - } - - fn matching_build_record() -> KwBuildRecord { - KwBuildRecord { - kernel_tree_id: "mainline".to_string(), - tree_path: "/home/user/linux".to_string(), - message_id: None, - branch: "patchset-2026-08-01-17-30-00".to_string(), - arch: Some("x86".to_string()), - image_path: Some("/home/user/linux/arch/x86/boot/bzImage".to_string()), - output_dir: None, - kernelrelease: Some("6.17.0".to_string()), - log_path: String::new(), - built_at: "2026-08-01T18:10:00Z".to_string(), - success: true, - } - } - - fn deploy_options(acknowledged: bool) -> DeployOptions { - DeployOptions { - reboot: false, - force: true, - boot_once_acknowledged: acknowledged, - } - } - - fn deploy_request() -> StartRequest { - let mut request = start_request(); - request.deploy = Some(deploy_options(false)); - request - } - - /// History for a deploy-alone start: answers the record lookup for the - /// requested branch and panics if a deploy writes a build record. - fn deploy_history(record: Option) -> MockKwHistoryStore { - let mut history = MockKwHistoryStore::new(); - history - .expect_build_records() - .withf(|kernel_tree_id, branch| { - kernel_tree_id == "mainline" && branch == "patchset-2026-08-01-17-30-00" - }) - .returning(move |_, _| Ok((record.clone(), record.clone()))); - history.expect_record_build().times(0); - history - } - - fn env_with_kw() -> MockEnvTrait { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env - } - - fn spawn_deploy_actor( - test_name: &str, - history: MockKwHistoryStore, - fs: MockFileSystemTrait, - ) -> (KwHandle, Arc, PathBuf) { - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - spawn_full_actor(test_name, history, shell, fs, env_with_kw()) - } - - /// History answers for an actor whose jobs complete: no patchset - /// link, build-record writes accepted and dropped. - fn quiet_history() -> MockKwHistoryStore { - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .returning(|_, _| Ok(None)); - history.expect_record_build().returning(|_| Ok(())); - history - } - - /// A history double that captures written build records and answers - /// the patchset-link lookup with `apply_record`. - fn recording_history( - apply_record: Option, - ) -> (MockKwHistoryStore, Arc>>) { - let builds = Arc::new(Mutex::new(Vec::new())); - let builds_in_store = Arc::clone(&builds); - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .returning(move |_, _| Ok(apply_record.clone())); - history.expect_record_build().returning(move |record| { - builds_in_store.lock().unwrap().push(record); - Ok(()) - }); - (history, builds) - } - - /// Spawns the actor with every dependency explicit, a real temp log - /// dir, and the [`FakeProcess`] exposed so tests drive the "running" - /// process. - fn spawn_full_actor( - test_name: &str, - history: MockKwHistoryStore, - shell: MockShellTrait, - fs: MockFileSystemTrait, - env: MockEnvTrait, - ) -> (KwHandle, Arc, PathBuf) { - let process = Arc::new(FakeProcess::new()); - let log_dir = tmp_log_dir(test_name); - let handle = KwActor::spawn( - Arc::new(history), - process.clone(), - Arc::new(shell), - Arc::new(fs), - Arc::new(env), - log_dir.clone(), - ); - (handle, process, log_dir) - } - - /// Spawns the actor with a real temp log dir and exposes the - /// [`FakeProcess`] so tests drive the "running" process. The env mock - /// has kw on PATH. - fn spawn_job_actor_with_mocks( - test_name: &str, - shell: MockShellTrait, - fs: MockFileSystemTrait, - ) -> (KwHandle, Arc, PathBuf) { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - spawn_full_actor(test_name, quiet_history(), shell, fs, env) - } - - fn spawn_job_actor(test_name: &str) -> (KwHandle, Arc, PathBuf) { - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - spawn_job_actor_with_mocks(test_name, shell, ready_fs()) - } - - fn apply_record() -> KwApplyRecord { - KwApplyRecord { - message_id: "msg-1".to_string(), - kernel_tree_id: "mainline".to_string(), - tree_path: "/home/user/linux".to_string(), - applied_branch: "patchset-2026-08-01-17-30-00".to_string(), - base_branch: "master".to_string(), - applied_at: "2026-08-01T17:30:00Z".to_string(), - } - } - - #[tokio::test] - async fn record_apply_writes_through_history_store() { - let expected = apply_record(); - let mut history = MockKwHistoryStore::new(); - history - .expect_record_apply() - .withf(move |record| *record == expected) - .times(1) - .returning(|_| Ok(())); - let handle = spawn_test_actor( - "record-apply", - history, - MockShellTrait::new(), - MockFileSystemTrait::new(), - MockEnvTrait::new(), - ); - - handle.record_apply(apply_record()).await.unwrap(); - handle.shutdown().await; - } - - #[tokio::test] - async fn record_apply_surfaces_store_errors() { - let mut history = MockKwHistoryStore::new(); - history - .expect_record_apply() - .times(1) - .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); - let handle = spawn_test_actor( - "record-apply-error", - history, - MockShellTrait::new(), - MockFileSystemTrait::new(), - MockEnvTrait::new(), - ); - - let err = handle.record_apply(apply_record()).await.unwrap_err(); - - assert!(matches!(err, KwError::History(_))); - handle.shutdown().await; - } - - #[tokio::test] - async fn get_status_reports_idle_before_any_job() { - let handle = spawn_test_actor( - "idle-status", - MockKwHistoryStore::new(), - MockShellTrait::new(), - MockFileSystemTrait::new(), - MockEnvTrait::new(), - ); - - let snapshot = handle.get_status().await.unwrap(); - - assert_eq!(KwJobStatus::Idle, snapshot.job); - assert_eq!(None, snapshot.restore_branch); - handle.shutdown().await; - } - - #[tokio::test] - async fn watch_status_receiver_sees_current_snapshot() { - let handle = spawn_test_actor( - "watch-idle", - MockKwHistoryStore::new(), - MockShellTrait::new(), - MockFileSystemTrait::new(), - MockEnvTrait::new(), - ); - - let receiver = handle.watch_status().await.unwrap(); - - assert_eq!(KwJobStatus::Idle, receiver.borrow().job); - handle.shutdown().await; - } - - #[tokio::test] - async fn get_readiness_composes_probes_and_head_branch() { - let tree = kernel_tree(Path::new("/home/user/linux")); - - let mut env = MockEnvTrait::new(); - env.expect_which() - .withf(|name| name == "kw") - .returning(|_| false); - env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| false); - fs.expect_is_dir().returning(|_| false); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - let mut history = MockKwHistoryStore::new(); - history - .expect_build_records() - .withf(|kernel_tree_id, branch| kernel_tree_id == "mainline" && branch == "for-next") - .times(1) - .returning(|_, _| Ok((None, None))); - let mut shell = MockShellTrait::new(); - shell - .expect_execute() - .withf(|cmd| { - cmd.program == "git" - && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] - }) - .times(1) - .returning(|_| { - Ok(ShellOutput { - stdout: b"for-next\n".to_vec(), - stderr: Vec::new(), - success: true, - }) - }); - let handle = spawn_test_actor("readiness", history, shell, fs, env); - - let readiness = handle.get_readiness("mainline", &tree, None).await.unwrap(); - - assert!(!readiness.kw_binary.available); - assert_eq!(TreeReadiness::Missing, readiness.tree); - assert_eq!( - Err(DeployAloneRefusal::TreeNotReady(TreeReadiness::Missing)), - readiness.deploy_alone - ); - assert_eq!(Some("for-next".to_string()), readiness.current_branch); - assert_eq!( - Err(RemoteRefusal::NoRemotesConfigured), - readiness.deploy_remote - ); - assert_eq!(BootOnceState::Unknown, readiness.boot_once); - handle.shutdown().await; - } - - #[tokio::test] - async fn get_readiness_for_branch_looks_up_that_branch_not_head() { - let tree = kernel_tree(Path::new("/home/user/linux")); - - let mut env = MockEnvTrait::new(); - env.expect_which() - .withf(|name| name == "kw") - .returning(|_| false); - env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| false); - fs.expect_is_dir().returning(|_| false); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - let mut history = MockKwHistoryStore::new(); - history - .expect_build_records() - .withf(|kernel_tree_id, branch| kernel_tree_id == "mainline" && branch == "patchset-x") - .times(1) - .returning(|_, _| Ok((None, None))); - let mut shell = MockShellTrait::new(); - shell - .expect_execute() - .withf(|cmd| { - cmd.program == "git" - && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] - }) - .times(1) - .returning(|_| { - Ok(ShellOutput { - stdout: b"master\n".to_vec(), - stderr: Vec::new(), - success: true, - }) - }); - let handle = spawn_test_actor("readiness-for-branch", history, shell, fs, env); - - let readiness = handle - .get_readiness("mainline", &tree, Some("patchset-x")) - .await - .unwrap(); - - assert_eq!(Some("master".to_string()), readiness.current_branch); - assert_eq!(None, readiness.build_record); - handle.shutdown().await; - } - - /// Waits until the status leaves `Idle`/`Running` and returns the - /// terminal status. The receiver may have observed the `Running` - /// transition first, so a single `changed()` is not enough. The timeout - /// backstops against a wedged actor; tests that exercise the grace - /// periods run with paused time instead of waiting them out. - async fn wait_for_terminal_status( - watch: &mut watch::Receiver, - ) -> KwJobStatus { - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let status = watch.borrow().job.clone(); - if !matches!(status, KwJobStatus::Idle | KwJobStatus::Running { .. }) { - return status; - } - watch.changed().await.unwrap(); - } - }) - .await - .expect("status must reach a terminal state") - } - - async fn wait_for_running_phase( - watch: &mut watch::Receiver, - phase: KwPhase, - ) -> KwJobStatus { - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let status = watch.borrow().job.clone(); - if matches!( - status, - KwJobStatus::Running { phase: running, .. } if running == phase - ) { - return status; - } - watch.changed().await.unwrap(); - } - }) - .await - .expect("status must reach the expected running phase") - } - - #[tokio::test] - async fn start_build_replies_immediately_and_runs_in_background() { - let (handle, process, log_dir) = spawn_job_actor("start-immediate"); - - // start_build resolves while the spawned process is still running - // (no finish() was ever signaled). - let result = - tokio::time::timeout(Duration::from_secs(1), handle.start_build(start_request())) - .await - .expect("start_build must reply immediately"); - result.unwrap(); - - let spawned = process.spawned(); - assert_eq!(1, spawned.len()); - assert_eq!("kw", spawned[0].program); - assert_eq!(["build"], spawned[0].args.as_slice()); - assert_eq!(Path::new("/home/user/linux"), spawned[0].cwd); - assert!(spawned[0].log_path.starts_with(&log_dir)); - - let snapshot = handle.get_status().await.unwrap(); - assert!( - matches!( - snapshot.job, - KwJobStatus::Running { - kind: KwJobKind::Build, - phase: KwPhase::Building, - .. - } - ), - "unexpected status: {:?}", - snapshot.job - ); - - process.last_child().finish(0); - let mut watch = handle.watch_status().await.unwrap(); - let status = wait_for_terminal_status(&mut watch).await; - assert!( - matches!( - status, - KwJobStatus::Succeeded { - kind: KwJobKind::Build, - .. - } - ), - "unexpected status: {status:?}" - ); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_merges_extra_args_into_the_spawned_argv() { - let (handle, process, log_dir) = spawn_job_actor("extra-args"); - - let mut request = start_request(); - request.extra_args = [ - "--verbose", - "--alert=vv", - "--save-log-to", - "/tmp/x.log", - "--ccache", - ] - .into_iter() - .map(String::from) - .collect(); - handle.start_build(request).await.unwrap(); - - let spawned = process.spawned(); - // Reserved options are stripped: the user's --alert and --save-log-to - // do not reach kw; the rest passes through in order. - assert_eq!( - ["build", "--verbose", "--ccache"].as_slice(), - spawned[0].args.as_slice() - ); - - process.last_child().finish(0); - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn second_start_while_running_is_refused() { - let (handle, process, log_dir) = spawn_job_actor("busy"); - - handle.start_build(start_request()).await.unwrap(); - let second = handle.start_build(start_request()).await; - - assert!(matches!(second, Err(KwStartError::JobAlreadyRunning))); - - process.last_child().finish(0); - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn failed_build_reports_exit_code_and_log_path() { - let (handle, process, log_dir) = spawn_job_actor("failed"); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().write_log( - b" CC init/main.o\n\ - init/main.c:1691:2: error: #error broken\n\ - make: *** [Makefile:248: __sub-make] Error 2\n", - ); - process.last_child().finish(2); - - let status = wait_for_terminal_status(&mut watch).await; - - match status { - KwJobStatus::Failed { - kind, - phase, - exit_code, - log_path, - first_error, - } => { - assert_eq!(KwJobKind::Build, kind); - assert_eq!(KwPhase::Building, phase); - assert_eq!(Some(2), exit_code); - assert!(log_path.starts_with(&log_dir)); - assert_eq!( - Some("init/main.c:1691:2: error: #error broken"), - first_error.as_deref() - ); - } - other => panic!("expected Failed, got {other:?}"), - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn cancel_kills_process_group_and_reports_cancelled() { - let (handle, process, log_dir) = spawn_job_actor("cancel"); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - // The ack is immediate: process death is observed via the status, - // not the reply. - tokio::time::timeout(Duration::from_secs(1), handle.cancel()) - .await - .expect("cancel must ack immediately") - .unwrap(); - - assert!(process.last_child().was_killed()); - let status = wait_for_terminal_status(&mut watch).await; - match status { - KwJobStatus::Cancelled { - kind, - phase, - log_path, - } => { - assert_eq!(KwJobKind::Build, kind); - assert_eq!(KwPhase::Building, phase); - assert!(log_path.starts_with(&log_dir)); - } - other => panic!("expected Cancelled, got {other:?}"), - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn shutdown_kills_running_job() { - let (handle, process, log_dir) = spawn_job_actor("shutdown-kill"); - - handle.start_build(start_request()).await.unwrap(); - // shutdown() returns only after the kill escalation has completed. - handle.shutdown().await; - - assert!(process.last_child().was_killed()); - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn spawn_failure_refuses_start_and_stays_idle() { - let (handle, process, log_dir) = spawn_job_actor("spawn-fail"); - process.refuse_spawns(true); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::Spawn(_))); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - // A refused start must leave the actor able to accept a later one. - process.refuse_spawns(false); - handle.start_build(start_request()).await.unwrap(); - - process.last_child().finish(0); - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_refused_when_kw_binary_missing() { - let process = Arc::new(FakeProcess::new()); - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| false); - let log_dir = tmp_log_dir("kw-missing"); - let handle = KwActor::spawn( - Arc::new(MockKwHistoryStore::new()), - process.clone(), - // No shell or fs calls are expected: the missing binary - // short-circuits the start before any other probe or spawn. - Arc::new(MockShellTrait::new()), - Arc::new(MockFileSystemTrait::new()), - Arc::new(env), - log_dir.clone(), - ); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::KwBinaryMissing)); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_refused_when_tree_not_ready() { - let process = Arc::new(FakeProcess::new()); - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(|_| { - Ok(ShellOutput { - stdout: b"kw, version 0.10.0\n".to_vec(), - stderr: Vec::new(), - success: true, - }) - }); - // The kernel-root probes pass, but there is no .kw directory: kw - // init was never run in this tree. - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .returning(|path| path.file_name().is_none_or(|name| name != ".kw")); - fs.expect_is_file() - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - let log_dir = tmp_log_dir("tree-not-ready"); - let handle = KwActor::spawn( - Arc::new(MockKwHistoryStore::new()), - process.clone(), - Arc::new(shell), - Arc::new(fs), - Arc::new(env), - log_dir.clone(), - ); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::TreeNotReady(TreeReadiness::MissingKwDir) - )); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_allowed_when_kw_version_below_floor() { - // kw's shipped VERSION file is stale (`beta-0.9` even at the 0.10 - // tag): a below-floor report warns but never gates the start. - let (shell, _calls) = recording_shell(b"kw, version beta-0.9\n", CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("version-below", shell, ready_fs()); - - handle.start_build(start_request()).await.unwrap(); - assert_eq!(1, process.spawned().len()); - - process.last_child().finish(0); - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_switches_to_requested_branch_before_spawning() { - let (shell, calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("checkout-order", shell, ready_fs()); - - handle.start_build(start_request()).await.unwrap(); - - { - let calls = calls.lock().unwrap(); - let git_position = |subcommand: &str| { - calls - .iter() - .position(|call| { - call.first().map(String::as_str) == Some("git") - && call.iter().any(|part| part == subcommand) - }) - .expect("expected git call missing") - }; - let status = git_position("status"); - let head = git_position("--show-current"); - let switch = git_position("switch"); - assert!( - status < head && head < switch, - "checkout policy must probe dirty state, then HEAD, then switch: {calls:?}" - ); - // Untracked scratch files don't block a build; only tracked - // changes do. - assert_eq!( - &calls[status], - &command(&[ - "git", - "-C", - "/home/user/linux", - "status", - "--porcelain", - "--untracked-files=no" - ]) - ); - // The `--` keeps a branch named like a flag from being parsed - // as one. - assert_eq!( - &calls[switch], - &command(&[ - "git", - "-C", - "/home/user/linux", - "switch", - "--", - "patchset-2026-08-01-17-30-00" - ]) - ); - } - assert_eq!(1, process.spawned().len()); - - process.last_child().finish(0); - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_refused_when_worktree_is_dirty() { - let (shell, calls) = - recording_shell(KW_VERSION_OK, (b" M src/main.c\n", b"", true), SWITCH_OK); - let mut fs = MockFileSystemTrait::new(); - expect_ready_tree(&mut fs); - let (handle, process, log_dir) = spawn_job_actor_with_mocks("dirty", shell, fs); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::DirtyWorktree)); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - assert!(process.spawned().is_empty()); - // The refusal happens before any branch mutation. - assert!(!calls - .lock() - .unwrap() - .iter() - .any(|call| call.iter().any(|part| part == "switch"))); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_refused_when_git_state_is_unverifiable() { - let (shell, _calls) = recording_shell( - KW_VERSION_OK, - (b"", b"fatal: not a git repository\n", false), - SWITCH_OK, - ); - let mut fs = MockFileSystemTrait::new(); - expect_ready_tree(&mut fs); - let (handle, process, log_dir) = spawn_job_actor_with_mocks("git-probe-fail", shell, fs); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::GitStateProbe(_))); - assert!(err.to_string().contains("not a git repository")); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_refused_when_branch_switch_fails() { - let (shell, _calls) = recording_shell( - KW_VERSION_OK, - CLEAN_STATUS, - ( - b"error: pathspec 'no-such-branch' did not match any file(s) known to git\n", - false, - ), - ); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("switch-fail", shell, ready_fs()); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::CheckoutFailed(_))); - assert!(err.to_string().contains("did not match")); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_replies_immediately_and_runs_in_background() { - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-immediate", - deploy_history(Some(matching_build_record())), - deploy_ready_fs(), - ); - - let result = tokio::time::timeout( - Duration::from_secs(1), - handle.start_deploy(deploy_request()), - ) - .await - .expect("start_deploy must reply immediately"); - result.unwrap(); - - let spawned = process.spawned(); - assert_eq!(1, spawned.len()); - assert_eq!("kw", spawned[0].program); - assert_eq!( - [ - "deploy", - "--remote", - "root@box:22", - "--no-reboot", - "--force" - ] - .as_slice(), - spawned[0].args.as_slice() - ); - assert_eq!(Path::new("/home/user/linux"), spawned[0].cwd); - assert!(spawned[0].log_path.starts_with(&log_dir)); - assert!(spawned[0] - .log_path - .file_name() - .unwrap() - .to_string_lossy() - .starts_with("deploy-")); - - let snapshot = handle.get_status().await.unwrap(); - assert!( - matches!( - snapshot.job, - KwJobStatus::Running { - kind: KwJobKind::Deploy, - phase: KwPhase::Deploying, - .. - } - ), - "unexpected status: {:?}", - snapshot.job - ); - - process.last_child().finish(0); - let mut watch = handle.watch_status().await.unwrap(); - let status = wait_for_terminal_status(&mut watch).await; - assert!( - matches!( - status, - KwJobStatus::Succeeded { - kind: KwJobKind::Deploy, - .. - } - ), - "unexpected status: {status:?}" - ); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn deploy_exit_zero_with_initramfs_failure_succeeds_with_warnings() { - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-warnings", - deploy_history(Some(matching_build_record())), - deploy_ready_fs(), - ); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_deploy(deploy_request()).await.unwrap(); - process.last_child().write_log( - b"update-initramfs: Generating /boot/initrd.img-6.17.0\n\ - E: gzip compression (CONFIG_RD_GZIP) not supported by kernel\n\ - update-initramfs: failed for /boot/initrd.img-6.17.0 with 1.\n\ - Generating grub configuration file ...\n\ - Found linux image: /boot/vmlinuz-6.8.0-generic\n\ - done\n", - ); - process.last_child().finish(0); - - match wait_for_terminal_status(&mut watch).await { - KwJobStatus::Succeeded { kind, warnings, .. } => { - assert_eq!(KwJobKind::Deploy, kind); - assert_eq!( - vec![ - "update-initramfs: failed for /boot/initrd.img-6.17.0 with 1.".to_string(), - "GRUB did not list kernel 6.17.0".to_string(), - ], - warnings - ); - } - other => panic!("expected Succeeded, got {other:?}"), - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn build_then_deploy_checks_grub_for_the_release_it_just_built() { - let (handle, process, log_dir) = - spawn_deploy_actor("chain-warnings", quiet_history(), deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle - .start_build_then_deploy(deploy_request()) - .await - .unwrap(); - process.last_child().finish(0); - wait_for_running_phase(&mut watch, KwPhase::Deploying).await; - process.last_child().write_log( - b"Generating grub configuration file ...\n\ - Found linux image: /boot/vmlinuz-6.8.0-generic\n\ - Found linux image: /boot/Image-6.17.0-rc1\n\ - done\n", - ); - process.last_child().finish(0); - - match wait_for_terminal_status(&mut watch).await { - KwJobStatus::Succeeded { warnings, .. } => { - assert_eq!( - vec!["GRUB did not list kernel 6.17.0".to_string()], - warnings - ); - } - other => panic!("expected Succeeded, got {other:?}"), - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_merges_extras_and_follows_reboot_force_options() { - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-argv", - deploy_history(Some(matching_build_record())), - deploy_ready_fs(), - ); - - let mut request = deploy_request(); - request.deploy = Some(DeployOptions { - reboot: true, - force: false, - boot_once_acknowledged: false, - }); - request.extra_args = [ - "--verbose", - "--local", - "--alert=n", - "--force", - "--remote", - "other:22", - ] - .into_iter() - .map(String::from) - .collect(); - handle.start_deploy(request).await.unwrap(); - - let spawned = process.spawned(); - assert_eq!( - ["deploy", "--remote", "root@box:22", "--reboot", "--verbose"].as_slice(), - spawned[0].args.as_slice() - ); - - process.last_child().finish(0); - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_without_a_build_record() { - let (handle, process, log_dir) = - spawn_deploy_actor("deploy-no-record", deploy_history(None), deploy_ready_fs()); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::DeployAloneRefused(DeployAloneRefusal::NoBuildRecord) - )); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_when_last_build_failed() { - let mut record = matching_build_record(); - record.success = false; - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-failed-build", - deploy_history(Some(record)), - deploy_ready_fs(), - ); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::DeployAloneRefused(DeployAloneRefusal::LastBuildFailed) - )); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_on_tree_path_drift() { - let mut record = matching_build_record(); - record.tree_path = "/other/linux".to_string(); - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-drift", - deploy_history(Some(record)), - deploy_ready_fs(), - ); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::DeployAloneRefused(DeployAloneRefusal::TreePathDrift { .. }) - )); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_on_output_dir_mismatch() { - let mut record = matching_build_record(); - record.output_dir = Some("/cache/kw/envs/testenv".to_string()); - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-env", - deploy_history(Some(record)), - deploy_ready_fs(), - ); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::DeployAloneRefused(DeployAloneRefusal::OutputDirMismatch) - )); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_when_image_is_missing() { - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-no-image", - deploy_history(Some(matching_build_record())), - deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, false), - ); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::DeployAloneRefused(DeployAloneRefusal::ImageMissing) - )); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_when_remote_is_unresolved() { - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-no-remote", - MockKwHistoryStore::new(), - deploy_fs("", DEPLOY_BOOT_ONCE_OFF, true), - ); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::RemoteUnresolved(RemoteRefusal::NoRemotesConfigured) - )); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_when_boot_once_is_on_and_unacked() { - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-boot-once", - deploy_history(Some(matching_build_record())), - deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_ON, true), - ); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::BootOnceNotAcknowledged)); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_refused_when_latest_build_is_on_another_branch() { - let mut latest = matching_build_record(); - latest.branch = "other".to_string(); - let mut history = MockKwHistoryStore::new(); - history - .expect_build_records() - .returning(move |_, _| Ok((None, Some(latest.clone())))); - history.expect_record_build().times(0); - let (handle, process, log_dir) = - spawn_deploy_actor("deploy-head-mismatch", history, deploy_ready_fs()); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::DeployAloneRefused(DeployAloneRefusal::HeadMismatch { .. }) - )); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_proceeds_when_boot_once_is_on_and_acked() { - let (handle, process, log_dir) = spawn_deploy_actor( - "deploy-boot-once-acked", - deploy_history(Some(matching_build_record())), - deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_ON, true), - ); - - let mut request = deploy_request(); - request.deploy = Some(deploy_options(true)); - handle.start_deploy(request).await.unwrap(); - assert_eq!(1, process.spawned().len()); - - process.last_child().finish(0); - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_deploy_post_switch_refusal_rolls_the_switch_back() { - let git = GitStub::on_branch("master"); - let (handle, process, log_dir) = spawn_full_actor( - "deploy-rollback", - deploy_history(None), - git.shell(), - deploy_ready_fs(), - env_with_kw(), - ); - - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); - - assert!(matches!( - err, - KwStartError::DeployAloneRefused(DeployAloneRefusal::NoBuildRecord) - )); - assert!(process.spawned().is_empty()); - assert_eq!(git.head(), "master"); - assert!(matches!( - handle.restore_previous_branch().await, - Err(KwError::NoRecordedBranch) - )); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { - let (history, builds) = recording_history(None); - let (handle, process, log_dir) = - spawn_deploy_actor("chain-success", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - let mut request = deploy_request(); - request.extra_args = ["--verbose", "--ccache", "--alert=n"] - .into_iter() - .map(String::from) - .collect(); - tokio::time::timeout( - Duration::from_secs(1), - handle.start_build_then_deploy(request), - ) - .await - .expect("start_build_then_deploy must reply immediately") - .unwrap(); - - assert_eq!(1, process.spawned().len()); - assert_eq!( - ["build", "--verbose", "--ccache"].as_slice(), - process.spawned()[0].args.as_slice() - ); - assert!( - matches!( - handle.get_status().await.unwrap().job, - KwJobStatus::Running { - kind: KwJobKind::BuildThenDeploy, - phase: KwPhase::Building, - .. - } - ), - "unexpected status: {:?}", - handle.get_status().await.unwrap().job - ); - - let build = process.last_child(); - build.finish(0); - let deploying = wait_for_running_phase(&mut watch, KwPhase::Deploying).await; - match deploying { - KwJobStatus::Running { - kind, - phase, - log_path, - .. - } => { - assert_eq!(KwJobKind::BuildThenDeploy, kind); - assert_eq!(KwPhase::Deploying, phase); - assert!(log_path - .file_name() - .unwrap() - .to_string_lossy() - .starts_with("deploy-")); - } - other => panic!("expected Running Deploying, got {other:?}"), - } - - let spawned = process.spawned(); - assert_eq!(2, spawned.len()); - assert_eq!( - [ - "deploy", - "--remote", - "root@box:22", - "--no-reboot", - "--force", - "--verbose", - ] - .as_slice(), - spawned[1].args.as_slice() - ); - assert_eq!(Path::new("/home/user/linux"), spawned[1].cwd); - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - assert!(builds[0].success); - } - - process.last_child().finish(0); - let status = wait_for_terminal_status(&mut watch).await; - assert!( - matches!( - status, - KwJobStatus::Succeeded { - kind: KwJobKind::BuildThenDeploy, - .. - } - ), - "unexpected status: {status:?}" - ); - assert_eq!(1, builds.lock().unwrap().len()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_then_deploy_skips_deploy_when_the_build_fails() { - let (history, builds) = recording_history(None); - let (handle, process, log_dir) = - spawn_deploy_actor("chain-build-fail", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle - .start_build_then_deploy(deploy_request()) - .await - .unwrap(); - process.last_child().finish(2); - - let status = wait_for_terminal_status(&mut watch).await; - match status { - KwJobStatus::Failed { - kind, - phase, - exit_code, - .. - } => { - assert_eq!(KwJobKind::BuildThenDeploy, kind); - assert_eq!(KwPhase::Building, phase); - assert_eq!(Some(2), exit_code); - } - other => panic!("expected Failed Building, got {other:?}"), - } - assert_eq!(1, process.spawned().len()); - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - assert!(!builds[0].success); - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_then_deploy_cancel_in_building_skips_deploy_and_record() { - let (history, builds) = recording_history(None); - let (handle, process, log_dir) = - spawn_deploy_actor("chain-cancel-build", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle - .start_build_then_deploy(deploy_request()) - .await - .unwrap(); - handle.cancel().await.unwrap(); - - let status = wait_for_terminal_status(&mut watch).await; - match status { - KwJobStatus::Cancelled { kind, phase, .. } => { - assert_eq!(KwJobKind::BuildThenDeploy, kind); - assert_eq!(KwPhase::Building, phase); - } - other => panic!("expected Cancelled Building, got {other:?}"), - } - assert_eq!(1, process.spawned().len()); - assert!(process.last_child().was_killed()); - assert!(builds.lock().unwrap().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_then_deploy_cancel_then_exit_zero_skips_deploy() { - let (history, builds) = recording_history(None); - let (handle, process, log_dir) = - spawn_deploy_actor("chain-cancel-exit-zero", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - process.ignore_sigterm(true); - handle - .start_build_then_deploy(deploy_request()) - .await - .unwrap(); - handle.cancel().await.unwrap(); - process.last_child().finish(0); - - let status = wait_for_terminal_status(&mut watch).await; - match status { - KwJobStatus::Cancelled { kind, phase, .. } => { - assert_eq!(KwJobKind::BuildThenDeploy, kind); - assert_eq!(KwPhase::Building, phase); - } - other => panic!("expected Cancelled Building, got {other:?}"), - } - assert_eq!(1, process.spawned().len()); - assert!(builds.lock().unwrap().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_then_deploy_cancel_in_deploying_keeps_the_build_record() { - let (history, builds) = recording_history(None); - let (handle, process, log_dir) = - spawn_deploy_actor("chain-cancel-deploy", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle - .start_build_then_deploy(deploy_request()) - .await - .unwrap(); - process.last_child().finish(0); - let _ = wait_for_running_phase(&mut watch, KwPhase::Deploying).await; - assert_eq!(1, builds.lock().unwrap().len()); - - handle.cancel().await.unwrap(); - let status = wait_for_terminal_status(&mut watch).await; - match status { - KwJobStatus::Cancelled { kind, phase, .. } => { - assert_eq!(KwJobKind::BuildThenDeploy, kind); - assert_eq!(KwPhase::Deploying, phase); - } - other => panic!("expected Cancelled Deploying, got {other:?}"), - } - assert_eq!(2, process.spawned().len()); - assert!(process.last_child().was_killed()); - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - assert!(builds[0].success); - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_then_deploy_spawn_failure_at_boundary_keeps_the_build_record() { - let (history, builds) = recording_history(None); - let (handle, process, log_dir) = - spawn_deploy_actor("chain-spawn-fail", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle - .start_build_then_deploy(deploy_request()) - .await - .unwrap(); - let build = process.last_child(); - process.refuse_spawns(true); - build.finish(0); - - let status = wait_for_terminal_status(&mut watch).await; - match status { - KwJobStatus::Failed { - kind, - phase, - exit_code, - .. - } => { - assert_eq!(KwJobKind::BuildThenDeploy, kind); - assert_eq!(KwPhase::Deploying, phase); - assert_eq!(None, exit_code); - } - other => panic!("expected Failed Deploying with no exit, got {other:?}"), - } - assert_eq!(1, process.spawned().len()); - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - assert!(builds[0].success); - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn start_build_then_deploy_refused_when_remote_is_unresolved() { - let (handle, process, log_dir) = spawn_deploy_actor( - "chain-no-remote", - MockKwHistoryStore::new(), - deploy_fs("", DEPLOY_BOOT_ONCE_OFF, true), - ); - - let err = handle - .start_build_then_deploy(deploy_request()) - .await - .unwrap_err(); - - assert!(matches!( - err, - KwStartError::RemoteUnresolved(RemoteRefusal::NoRemotesConfigured) - )); - assert!(process.spawned().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn cancel_and_restore_without_job_are_immediate_errors() { - let handle = spawn_test_actor( - "no-job", - MockKwHistoryStore::new(), - MockShellTrait::new(), - MockFileSystemTrait::new(), - MockEnvTrait::new(), - ); - - let cancel = tokio::time::timeout(Duration::from_secs(1), handle.cancel()) - .await - .expect("cancel must reply immediately"); - let restore = - tokio::time::timeout(Duration::from_secs(1), handle.restore_previous_branch()) - .await - .expect("restore must reply immediately"); - - assert!(matches!(cancel, Err(KwError::NoJobRunning))); - assert!(matches!(restore, Err(KwError::NoRecordedBranch))); - handle.shutdown().await; - } - - #[tokio::test] - async fn restore_switches_back_to_pre_job_branch_and_is_consumed() { - let git = GitStub::on_branch("master"); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("restore", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - // The checkout policy left HEAD on the build branch. - assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); - assert_eq!( - Some("master"), - handle.get_status().await.unwrap().restore_branch.as_deref() - ); - process.last_child().finish(0); - let status = wait_for_terminal_status(&mut watch).await; - assert!(matches!(status, KwJobStatus::Succeeded { .. })); - assert_eq!( - Some("master"), - handle.get_status().await.unwrap().restore_branch.as_deref() - ); - - handle.restore_previous_branch().await.unwrap(); - assert_eq!(git.head(), "master"); - assert_eq!(None, handle.get_status().await.unwrap().restore_branch); - - // A successful restore consumes the context: a second restore has - // nothing to do. - let err = handle.restore_previous_branch().await.unwrap_err(); - assert!(matches!(err, KwError::NoRecordedBranch)); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn restore_refused_while_job_is_running() { - let git = GitStub::on_branch("master"); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("restore-running", git.shell(), ready_fs()); - - handle.start_build(start_request()).await.unwrap(); - let err = handle.restore_previous_branch().await.unwrap_err(); - assert!(matches!(err, KwError::JobRunning)); - - // The context survives the refusal: restore works once the job - // ends. - process.last_child().finish(0); - let mut watch = handle.watch_status().await.unwrap(); - let _ = wait_for_terminal_status(&mut watch).await; - handle.restore_previous_branch().await.unwrap(); - assert_eq!(git.head(), "master"); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn restore_refused_when_worktree_is_dirty() { - let git = GitStub::on_branch("master"); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("restore-dirty", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let _ = wait_for_terminal_status(&mut watch).await; - - git.set_dirty(true); - let err = handle.restore_previous_branch().await.unwrap_err(); - assert!(matches!(err, KwError::DirtyWorktree)); - // The refused restore did not touch the tree. - assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); - assert_eq!( - Some("master"), - handle.get_status().await.unwrap().restore_branch.as_deref() - ); - - // The context survives: clean the tree and retry. - git.set_dirty(false); - handle.restore_previous_branch().await.unwrap(); - assert_eq!(git.head(), "master"); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn restore_failure_keeps_the_context_for_a_retry() { - let git = GitStub::on_branch("master"); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("restore-fail", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let _ = wait_for_terminal_status(&mut watch).await; - - git.fail_switches_to(Some("master")); - let err = handle.restore_previous_branch().await.unwrap_err(); - assert!(matches!(err, KwError::CheckoutFailed(_))); - assert!(err.to_string().contains("resolve your current index")); - - git.fail_switches_to(None); - handle.restore_previous_branch().await.unwrap(); - assert_eq!(git.head(), "master"); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn refused_start_does_not_clobber_the_restore_context() { - let git = GitStub::on_branch("master"); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("restore-clobber", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let _ = wait_for_terminal_status(&mut watch).await; - assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); - - // This start is refused at spawn — after its HEAD probe and - // switch — and the switch is rolled back: the tree returns to the - // first job's branch and the recorded restore target is intact. - process.refuse_spawns(true); - let mut second = start_request(); - second.branch = "patchset-two".to_string(); - let err = handle.start_build(second).await.unwrap_err(); - assert!(matches!(err, KwStartError::Spawn(_))); - assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); - - handle.restore_previous_branch().await.unwrap(); - assert_eq!(git.head(), "master"); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn shutdown_stops_actor() { - let handle = spawn_test_actor( - "shutdown", - MockKwHistoryStore::new(), - MockShellTrait::new(), - MockFileSystemTrait::new(), - MockEnvTrait::new(), - ); - - handle.shutdown().await; - let err = handle.get_status().await.unwrap_err(); - - assert!(matches!(err, KwError::ActorUnavailable(_))); - } - - #[tokio::test] - async fn log_dir_creation_failure_refuses_start_and_stays_idle() { - let git = GitStub::on_branch("master"); - let mut fs = MockFileSystemTrait::new(); - expect_ready_tree(&mut fs); - fs.expect_create_dir_all().returning(|_| { - Err(FileSystemError::IoError(io::Error::other( - "read-only filesystem", - ))) - }); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("log-dir-fail", git.shell(), fs); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::Fs(_))); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); - assert!(process.spawned().is_empty()); - // The switch happened and was rolled back: the tree is back on the - // user's branch, and no restore target was recorded. - assert_eq!(git.head(), "master"); - assert!(matches!( - handle.restore_previous_branch().await, - Err(KwError::NoRecordedBranch) - )); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn rollback_failure_keeps_the_spawn_refusal() { - let git = GitStub::on_branch("master"); - // Going forward works; coming back fails. - git.fail_switches_to(Some("master")); - let (handle, process, log_dir) = - spawn_job_actor_with_mocks("rollback-fails", git.shell(), ready_fs()); - process.refuse_spawns(true); - - let err = handle.start_build(start_request()).await.unwrap_err(); - - assert!(matches!(err, KwStartError::Spawn(_))); - // The rollback failure is logged, not reported: the caller keeps - // the actionable refusal, and the tree honestly shows where HEAD - // is. No restore target was recorded. - assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); - assert!(matches!( - handle.restore_previous_branch().await, - Err(KwError::NoRecordedBranch) - )); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn cancel_racing_a_successful_exit_reports_success() { - let (handle, process, log_dir) = spawn_job_actor("cancel-race"); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - // Whether the actor processes the exit or the cancel first is - // timing-dependent, but the terminal status must be Succeeded - // either way: the process exited before any signal landed. - let _ = handle.cancel().await; - - let status = wait_for_terminal_status(&mut watch).await; - assert!( - matches!(status, KwJobStatus::Succeeded { .. }), - "expected Succeeded, got {status:?}" - ); - // Killing an already-finished process is a no-op, not a kill. - assert!(!process.last_child().was_killed()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn cancel_racing_a_failed_exit_reports_failure() { - let (handle, process, log_dir) = spawn_job_actor("cancel-race-fail"); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(2); - // Whether the actor processes the exit or the cancel first is - // timing-dependent, but a plain exit (not signal-terminated) means - // the process failed on its own before the signal landed: the - // terminal status must be Failed either way, so the build history - // records a failure rather than a cancel. - let _ = handle.cancel().await; - - let status = wait_for_terminal_status(&mut watch).await; - assert!( - matches!( - status, - KwJobStatus::Failed { - exit_code: Some(2), - .. - } - ), - "expected Failed(2), got {status:?}" - ); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - // Paused time: the runtime auto-advances through the grace-period - // timers instead of burning wall-clock seconds on them. - #[tokio::test(start_paused = true)] - async fn cancel_escalates_to_sigkill_when_sigterm_is_ignored() { - let (handle, process, log_dir) = spawn_job_actor("sigkill-escalation"); - process.ignore_sigterm(true); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - handle.cancel().await.unwrap(); - - let status = wait_for_terminal_status(&mut watch).await; - assert!( - matches!(status, KwJobStatus::Cancelled { .. }), - "expected Cancelled, got {status:?}" - ); - let child = process.last_child(); - assert!(child.was_killed()); - assert!(child.was_force_killed()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn successful_build_writes_a_full_build_record() { - let (history, builds) = recording_history(Some(apply_record())); - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = - spawn_full_actor("build-record", history, shell, built_tree_fs(), { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env - }); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let status = wait_for_terminal_status(&mut watch).await; - assert!(matches!(status, KwJobStatus::Succeeded { .. })); - - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - let record = &builds[0]; - assert_eq!("mainline", record.kernel_tree_id); - assert_eq!("/home/user/linux", record.tree_path); - assert_eq!(Some("msg-1"), record.message_id.as_deref()); - assert_eq!("patchset-2026-08-01-17-30-00", record.branch); - assert_eq!(Some("x86"), record.arch.as_deref()); - assert_eq!( - Some("/home/user/linux/arch/x86/boot/bzImage"), - record.image_path.as_deref() - ); - assert_eq!(None, record.output_dir); - assert_eq!(Some("6.17.0"), record.kernelrelease.as_deref()); - assert!(record.log_path.starts_with(log_dir.to_str().unwrap())); - assert!(record.success); - // The readiness latest-lookup parses built_at as RFC3339. - assert!(chrono::DateTime::parse_from_rfc3339(&record.built_at).is_ok()); - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn failed_build_writes_a_failure_record() { - let (history, builds) = recording_history(Some(apply_record())); - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = - spawn_full_actor("failed-record", history, shell, built_tree_fs(), { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env - }); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(2); - let status = wait_for_terminal_status(&mut watch).await; - assert!(matches!( - status, - KwJobStatus::Failed { - exit_code: Some(2), - .. - } - )); - - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - let record = &builds[0]; - assert!(!record.success); - // Config/env facts are still recorded; what the build never - // produced is not — a stale image from an earlier build must - // not leak into a failure record. - assert_eq!(Some("x86"), record.arch.as_deref()); - assert_eq!(None, record.image_path); - assert_eq!(None, record.kernelrelease); - assert_eq!(Some("msg-1"), record.message_id.as_deref()); - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn cancelled_build_writes_no_record() { - let (history, builds) = recording_history(None); - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, _process, log_dir) = - spawn_full_actor("cancel-record", history, shell, ready_fs(), { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env - }); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - handle.cancel().await.unwrap(); - let status = wait_for_terminal_status(&mut watch).await; - - assert!(matches!(status, KwJobStatus::Cancelled { .. })); - assert!(builds.lock().unwrap().is_empty()); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn lost_exit_status_records_a_failed_build() { - let (history, builds) = recording_history(None); - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = - spawn_full_actor("wait-failure", history, shell, ready_fs(), { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env - }); - let mut watch = handle.watch_status().await.unwrap(); - process.fail_waits(true); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let status = wait_for_terminal_status(&mut watch).await; - - // A lost exit status is an honest failure: the record must not - // become deploy-alone evidence. - assert!(matches!( - status, - KwJobStatus::Failed { - exit_code: None, - .. - } - )); - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - assert!(!builds[0].success); - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn build_record_write_failure_keeps_the_terminal_status() { - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .returning(|_, _| Ok(None)); - history - .expect_record_build() - .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = - spawn_full_actor("record-write-fails", history, shell, ready_fs(), { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env - }); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let status = wait_for_terminal_status(&mut watch).await; - - // The build's real outcome reached the user; a history-write - // failure must not turn it into a reported failure. - assert!(matches!(status, KwJobStatus::Succeeded { .. })); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn build_record_keeps_no_patchset_link_when_apply_lookup_fails() { - let builds = Arc::new(Mutex::new(Vec::new())); - let builds_in_store = Arc::clone(&builds); - let mut history = MockKwHistoryStore::new(); - history.expect_apply_record_for_branch().returning(|_, _| { - Err(FileSystemError::IoError(io::Error::other( - "corrupt history", - ))) - }); - history.expect_record_build().returning(move |record| { - builds_in_store.lock().unwrap().push(record); - Ok(()) - }); - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = - spawn_full_actor("link-lookup-fails", history, shell, ready_fs(), { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env - }); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let _ = wait_for_terminal_status(&mut watch).await; - - // The lookup error must not drop the record, only the link. - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - assert_eq!(None, builds[0].message_id); - assert!(builds[0].success); - } - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[tokio::test] - async fn successful_build_with_active_env_records_the_output_dir() { - // env.current is read once, at accept time. The completion record - // must describe the env the build ran under — the snapshot — not - // the tree's env state at whatever time the job ends. - let env_current_reads = Arc::new(AtomicU64::new(0)); - let env_current_reads_in_fs = Arc::clone(&env_current_reads); - let mut fs = MockFileSystemTrait::new(); - // With an env active the build artifacts live only under O=; the - // source tree is clean. - fs.expect_is_dir() - .returning(|path| path != Path::new("/home/user/linux/include/config")); - fs.expect_is_file() - .returning(|path| path != Path::new("/home/user/linux/.config")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(move |path| { - if path.ends_with("env.current") { - env_current_reads_in_fs.fetch_add(1, Ordering::SeqCst); - Ok("testenv\n".to_string()) - } else if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - // Every boot-dir probe answers with an image inside the probed - // dir: the record's image path shows which build root was used. - fs.expect_read_dir().returning(|path| { - if path.ends_with("arch/x86/boot") { - Ok(vec![path.join("bzImage")]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_metadata() - .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all().returning(|_| Ok(())); - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env.expect_var() - .returning(|_| Ok("/home/user/.cache".to_string())); - let (history, builds) = recording_history(None); - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - let (handle, process, log_dir) = spawn_full_actor("env-build", history, shell, fs, env); - let mut watch = handle.watch_status().await.unwrap(); - - handle.start_build(start_request()).await.unwrap(); - process.last_child().finish(0); - let status = wait_for_terminal_status(&mut watch).await; - assert!(matches!(status, KwJobStatus::Succeeded { .. })); - - { - let builds = builds.lock().unwrap(); - assert_eq!(1, builds.len()); - let record = &builds[0]; - let output_dir = record - .output_dir - .as_deref() - .expect("an env build records its O= dir"); - assert!( - output_dir.contains("/kw/envs/") && output_dir.ends_with("/testenv"), - "unexpected output dir: {output_dir}" - ); - let image = record.image_path.as_deref().expect("image recorded"); - assert!( - image.starts_with(output_dir) && image.ends_with("bzImage"), - "image {image} must be probed under the env's output dir {output_dir}" - ); - assert_eq!(Some("x86"), record.arch.as_deref()); - assert_eq!(Some("6.17.0"), record.kernelrelease.as_deref()); - assert!(record.success); - } - // Read at accept time only: the completion record uses the - // snapshot, never a re-resolve. - assert_eq!(1, env_current_reads.load(Ordering::SeqCst)); - - handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } -} +mod actor_test; +#[cfg(test)] +mod deploy_test; From b77e4213f020ea163282cf35d6370eba4e13beff Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:11:19 -0300 Subject: [PATCH 03/61] fix(lore): propagate mbox read errors instead of panicking Signed-off-by: lorenzoberts --- src/lore/infrastructure/patchset_parser.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index fd81fa6..79681e4 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -40,24 +40,30 @@ impl PatchsetParser for MboxPatchsetParser { } if self.fs.exists(cover_letter_path) && self.fs.is_file(cover_letter_path) { - extract_patches(&*self.fs, cover_letter_path, &mut patches); + extract_patches(&*self.fs, cover_letter_path, &mut patches)?; } - extract_patches(&*self.fs, patchset_path, &mut patches); + extract_patches(&*self.fs, patchset_path, &mut patches)?; Ok(patches) } } -fn extract_patches(fs: &dyn FileSystemTrait, mbox_path: &Path, patches: &mut Vec) { +fn extract_patches( + fs: &dyn FileSystemTrait, + mbox_path: &Path, + patches: &mut Vec, +) -> Result<(), String> { let mut current_patch = String::new(); let mut is_reading_patch = false; let mut is_last_line = false; - let mbox_reader = fs.open_bufreader(mbox_path).unwrap(); + let mbox_reader = fs + .open_bufreader(mbox_path) + .map_err(|err| format!("{}: {err}", mbox_path.display()))?; for line in mbox_reader.lines() { - let line = line.unwrap(); + let line = line.map_err(|err| format!("{}: {err}", mbox_path.display()))?; if line.starts_with("Subject: ") { is_reading_patch = true; @@ -90,6 +96,8 @@ fn extract_patches(fs: &dyn FileSystemTrait, mbox_path: &Path, patches: &mut Vec if !current_patch.is_empty() { patches.push(current_patch); } + + Ok(()) } /// Splits a raw patch string into `(cover, diff)` at the first `\n---\n` separator. From a33fa7fc33b007b22c8a99a80eb2a5956331a5e8 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:12:31 -0300 Subject: [PATCH 04/61] fix(lore): tolerate malformed patch tags and list pages Signed-off-by: lorenzoberts --- src/lore/application/service.rs | 6 +++- src/lore/domain/patch.rs | 44 ++++++++++++++++++++++++++++-- src/lore/domain/patchset.rs | 4 ++- src/lore/infrastructure/parsers.rs | 22 ++++++++------- 4 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index 38bfec7..2a2f966 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -225,7 +225,11 @@ impl LoreService { match gateway.fetch_patch_feed_page(target_list, offset) { Ok(body) => { let feed = parsers::parse_patch_feed(&body).map_err(LoreError::Parse)?; - let entry = self.cache.feeds.get_mut(target_list).unwrap(); + let Some(entry) = self.cache.feeds.get_mut(target_list) else { + return Err(LoreError::Parse(format!( + "feed cache entry missing for {target_list}" + ))); + }; entry.index.process_feed_page(feed); entry.index.advance_offset(); } diff --git a/src/lore/domain/patch.rs b/src/lore/domain/patch.rs index 996d749..42918e5 100644 --- a/src/lore/domain/patch.rs +++ b/src/lore/domain/patch.rs @@ -94,7 +94,9 @@ impl Patch { fn set_version(&mut self, patch_tag: &str, re_patch_version: &Regex) { if let Some(capture) = re_patch_version.captures(patch_tag) { if let Some(version) = capture.get(1) { - self.version = version.as_str().parse().unwrap(); + if let Ok(version) = version.as_str().parse() { + self.version = version; + } } } } @@ -102,7 +104,9 @@ impl Patch { fn set_number_in_series(&mut self, patch_tag: &str, re_patch_series: &Regex) { if let Some(capture) = re_patch_series.captures(patch_tag) { if let Some(number_in_series) = capture.get(1) { - self.number_in_series = number_in_series.as_str().parse().unwrap(); + if let Ok(number_in_series) = number_in_series.as_str().parse() { + self.number_in_series = number_in_series; + } } } } @@ -110,7 +114,9 @@ impl Patch { fn set_total_in_series(&mut self, patch_tag: &str, re_patch_series: &Regex) { if let Some(capture) = re_patch_series.captures(patch_tag) { if let Some(total_in_series) = capture.get(2) { - self.total_in_series = total_in_series.as_str().parse().unwrap(); + if let Ok(total_in_series) = total_in_series.as_str().parse() { + self.total_in_series = total_in_series; + } } } } @@ -288,4 +294,36 @@ mod tests { assert_eq!(3, patch.number_in_series(), "Wrong number in series!"); assert_eq!(42, patch.total_in_series(), "Wrong total in series!"); } + + #[test] + fn update_patch_metadata_keeps_default_version_when_unparseable() { + let patch_regex = PatchRegex::new(); + let mut patch = Patch { + title: + "[PATCH v99999999999999999999] hitchhiker/guide: Life, the Universe and Everything" + .to_string(), + author: Author { + name: "Foo Bar".to_string(), + email: "foo@bar.foo.bar".to_string(), + }, + version: 1, + number_in_series: 1, + total_in_series: 1, + message_id: MessageID { + href: "http://lore.kernel.org/some-list/1234-2-foo@bar.foo.bar".to_string(), + }, + in_reply_to: None, + updated: "2024-07-06T19:16:53Z".to_string(), + }; + + patch.update_patch_metadata(&patch_regex); + + assert_eq!( + "hitchhiker/guide: Life, the Universe and Everything", + patch.title() + ); + assert_eq!(1, patch.version()); + assert_eq!(1, patch.number_in_series()); + assert_eq!(1, patch.total_in_series()); + } } diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index 92140c8..50319b8 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -99,7 +99,9 @@ impl PatchFeedIndex { fn update_representative_ids(&mut self, new_ids: Vec) { for id in new_ids { - let patch = self.patches_by_id.get(&id).unwrap(); + let Some(patch) = self.patches_by_id.get(&id) else { + continue; + }; let number_in_series = patch.number_in_series(); if number_in_series > 1 { diff --git a/src/lore/infrastructure/parsers.rs b/src/lore/infrastructure/parsers.rs index 2a5c5e4..55929ce 100644 --- a/src/lore/infrastructure/parsers.rs +++ b/src/lore/infrastructure/parsers.rs @@ -24,22 +24,24 @@ pub fn parse_available_lists(html: &str) -> Vec { let pre_blocks: Vec<&str> = RE_PRE_BLOCK .captures_iter(html) - .map(|cap| cap.get(1).unwrap().as_str()) + .filter_map(|cap| cap.get(1).map(|m| m.as_str())) .collect(); if pre_blocks.len() < 3 { return available_lists; } - for capture in RE_LIST_NAME.captures_iter(pre_blocks[2]) { - let name = capture.get(1).unwrap().as_str().trim(); - list_names.push(name); - } - - for capture in RE_LIST_DESCRIPTION.captures_iter(pre_blocks[2]) { - let description = capture.get(1).unwrap().as_str().trim(); - list_descriptions.push(description); - } + list_names.extend( + RE_LIST_NAME + .captures_iter(pre_blocks[2]) + .filter_map(|capture| capture.get(1).map(|m| m.as_str().trim())), + ); + + list_descriptions.extend( + RE_LIST_DESCRIPTION + .captures_iter(pre_blocks[2]) + .filter_map(|capture| capture.get(1).map(|m| m.as_str().trim())), + ); for (name, description) in list_names.into_iter().zip(list_descriptions) { if name == "all" { From d22e3c574eca33931d3d36e71e04db332895e96a Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:12:57 -0300 Subject: [PATCH 05/61] fix(infrastructure): keep error hooks from panicking on terminal restore Signed-off-by: lorenzoberts --- src/infrastructure/errors.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/infrastructure/errors.rs b/src/infrastructure/errors.rs index 452e926..7ab3911 100644 --- a/src/infrastructure/errors.rs +++ b/src/infrastructure/errors.rs @@ -1,4 +1,8 @@ -use std::{error::Error, panic}; +use std::{ + error::Error, + io::{self, Write}, + panic, +}; use color_eyre::{ config::HookBuilder, @@ -19,14 +23,18 @@ pub fn install_hooks() -> Result<()> { // convert from a color_eyre PanicHook to a standard panic hook let panic_hook = panic_hook.into_panic_hook(); panic::set_hook(Box::new(move |panic_info| { - restore().unwrap(); + if let Err(err) = restore() { + let _ = writeln!(io::stderr(), "failed to restore terminal: {err}"); + } panic_hook(panic_info); })); // convert from a color_eyre EyreHook to a eyre ErrorHook let eyre_hook = eyre_hook.into_eyre_hook(); set_hook(Box::new(move |error: &(dyn Error + 'static)| { - restore().unwrap(); + if let Err(err) = restore() { + let _ = writeln!(io::stderr(), "failed to restore terminal: {err}"); + } eyre_hook(error) }))?; From 1beb9fb5bba0dba6debfd1ba2d84c20471260c25 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:13:42 -0300 Subject: [PATCH 06/61] refactor(lore): state regex validity with expect Signed-off-by: lorenzoberts --- src/lore/application/service.rs | 5 +++-- src/lore/domain/patch.rs | 7 ++++--- src/lore/infrastructure/parsers.rs | 12 +++++++----- src/lore/infrastructure/patchset_parser.rs | 5 +++-- 4 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index 2a2f966..08454f9 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -322,8 +322,9 @@ impl LoreService { git_signature: &str, git_send_email_options: &str, ) -> Result, LoreError> { - static RE_MESSAGE_ID: LazyLock = - LazyLock::new(|| Regex::new(r"(?m)^Message-Id: <(.*?)>").unwrap()); + static RE_MESSAGE_ID: LazyLock = LazyLock::new(|| { + Regex::new(r"(?m)^Message-Id: <(.*?)>").expect("valid message id regex") + }); let gateway = Arc::clone(&self.patch_html_gateway); let mut commands = Vec::new(); diff --git a/src/lore/domain/patch.rs b/src/lore/domain/patch.rs index 42918e5..dd6bc4c 100644 --- a/src/lore/domain/patch.rs +++ b/src/lore/domain/patch.rs @@ -136,9 +136,10 @@ impl Default for PatchRegex { impl PatchRegex { pub fn new() -> PatchRegex { - let re_patch_tag = Regex::new(r"(?i)\[[^\]]*(PATCH|RFC)[^\[]*\]").unwrap(); - let re_patch_version = Regex::new(r"[v|V] *(\d+)").unwrap(); - let re_patch_series = Regex::new(r"(\d+) */ *(\d+)").unwrap(); + let re_patch_tag = + Regex::new(r"(?i)\[[^\]]*(PATCH|RFC)[^\[]*\]").expect("valid patch tag regex"); + let re_patch_version = Regex::new(r"[v|V] *(\d+)").expect("valid patch version regex"); + let re_patch_series = Regex::new(r"(\d+) */ *(\d+)").expect("valid patch series regex"); PatchRegex { re_patch_tag, diff --git a/src/lore/infrastructure/parsers.rs b/src/lore/infrastructure/parsers.rs index 55929ce..cb5baa7 100644 --- a/src/lore/infrastructure/parsers.rs +++ b/src/lore/infrastructure/parsers.rs @@ -12,11 +12,13 @@ pub fn parse_patch_feed(xml: &str) -> Result { /// sorted [`Vec`]. pub fn parse_available_lists(html: &str) -> Vec { static RE_PRE_BLOCK: LazyLock = - LazyLock::new(|| Regex::new(r#"(?s)
(.*?)
"#).unwrap()); - static RE_LIST_NAME: LazyLock = - LazyLock::new(|| Regex::new(r#"(?s)(.*?)"#).unwrap()); - static RE_LIST_DESCRIPTION: LazyLock = - LazyLock::new(|| Regex::new(r#"(?s)\s*(.*?)\s*\*"#).unwrap()); + LazyLock::new(|| Regex::new(r#"(?s)
(.*?)
"#).expect("valid pre block regex")); + static RE_LIST_NAME: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)(.*?)"#).expect("valid list name regex") + }); + static RE_LIST_DESCRIPTION: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)\s*(.*?)\s*\*"#).expect("valid list description regex") + }); let mut list_names: Vec<&str> = Vec::new(); let mut list_descriptions: Vec<&str> = Vec::new(); diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index 79681e4..d012618 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -165,11 +165,12 @@ pub fn extract_git_reply_command( } static RE_FULL_GIT_COMMAND: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?s)git-send-email\(1\):(.*?)/path/to/YOUR_REPLY"#).unwrap() + Regex::new(r#"(?s)git-send-email\(1\):(.*?)/path/to/YOUR_REPLY"#) + .expect("valid full git command regex") }); static RE_LONG_OPTIONS: LazyLock = - LazyLock::new(|| Regex::new(r"--[^\s=]+=[^\s]+").unwrap()); + LazyLock::new(|| Regex::new(r"--[^\s=]+=[^\s]+").expect("valid long options regex")); if let Some(capture) = RE_FULL_GIT_COMMAND.captures(patch_html) { if let Some(full_git_command_match) = capture.get(1) { From 103cc3aafd21a3fc83d091dc6bc81d0e50644889 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:31:57 -0300 Subject: [PATCH 07/61] test(input): build input contexts and key events from Default InputContext and CurrentScreen now derive Default, and tests build them with struct literals. KeyInput cannot derive Default because KeyCode and KeyEventKind do not implement it, so its Default is a null press with no modifiers. Signed-off-by: lorenzoberts --- src/app/screens/mod.rs | 3 +- src/input/actor.rs | 65 +++++++++++++++++++++++++++--------------- src/input/context.rs | 41 +------------------------- src/input/event.rs | 21 ++++---------- src/input/mapper.rs | 62 +++++++++++++++++++++++++++++++--------- src/terminal/actor.rs | 10 +++++-- 6 files changed, 106 insertions(+), 96 deletions(-) diff --git a/src/app/screens/mod.rs b/src/app/screens/mod.rs index 4659f6f..e476cf0 100644 --- a/src/app/screens/mod.rs +++ b/src/app/screens/mod.rs @@ -5,8 +5,9 @@ pub mod kw_ops; pub mod latest; pub mod mail_list; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum CurrentScreen { + #[default] MailingListSelection, BookmarkedPatchsets, LatestPatchsets, diff --git a/src/input/actor.rs b/src/input/actor.rs index 71d6182..06953e1 100644 --- a/src/input/actor.rs +++ b/src/input/actor.rs @@ -156,11 +156,17 @@ mod tests { use super::*; fn mailing_list_context() -> InputContext { - InputContext::new(CurrentScreen::MailingListSelection) + InputContext { + current_screen: CurrentScreen::MailingListSelection, + ..Default::default() + } } fn details_context() -> InputContext { - InputContext::new(CurrentScreen::PatchsetDetails) + InputContext { + current_screen: CurrentScreen::PatchsetDetails, + ..Default::default() + } } fn spawn_test_actor( @@ -176,10 +182,12 @@ mod tests { async fn key_event_in_mailing_list_context_delivers_navigate_down() { let mut session = MockTerminalSessionApi::new(); // First poll returns the key; subsequent polls time-out (return None). - session - .expect_poll_event() - .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::Down))))); + session.expect_poll_event().times(1).returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Down, + ..Default::default() + }))) + }); session.expect_poll_event().returning(|_| Ok(None)); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); @@ -198,10 +206,12 @@ mod tests { // before the key arrives. Second call returns the Esc key. // Remaining calls time-out. session.expect_poll_event().times(1).returning(|_| Ok(None)); - session - .expect_poll_event() - .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::Esc))))); + session.expect_poll_event().times(1).returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Esc, + ..Default::default() + }))) + }); session.expect_poll_event().returning(|_| Ok(None)); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); @@ -223,16 +233,20 @@ mod tests { async fn unmapped_terminal_event_is_discarded_without_error() { let mut session = MockTerminalSessionApi::new(); // F6 has no mapping in any screen — it should be silently dropped. - session - .expect_poll_event() - .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::F(6)))))); + session.expect_poll_event().times(1).returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::F(6), + ..Default::default() + }))) + }); // Second event — Down — is delivered so we can wait for it to confirm // the actor kept running after discarding the unmapped event. - session - .expect_poll_event() - .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::Down))))); + session.expect_poll_event().times(1).returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Down, + ..Default::default() + }))) + }); session.expect_poll_event().returning(|_| Ok(None)); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); @@ -285,13 +299,18 @@ mod tests { #[tokio::test] async fn popup_open_context_maps_escape_to_close_popup() { let mut session = MockTerminalSessionApi::new(); - session - .expect_poll_event() - .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::Esc))))); + session.expect_poll_event().times(1).returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Esc, + ..Default::default() + }))) + }); session.expect_poll_event().returning(|_| Ok(None)); - let context = details_context().with_popup_open(true); + let context = InputContext { + popup_open: true, + ..details_context() + }; let (input_handle, _terminal_handle) = spawn_test_actor(session, context); let (sub_tx, mut sub_rx) = mpsc::channel::(8); input_handle.subscribe_app(sub_tx).await.unwrap(); diff --git a/src/input/context.rs b/src/input/context.rs index dc75591..7d4229e 100644 --- a/src/input/context.rs +++ b/src/input/context.rs @@ -1,7 +1,7 @@ use crate::app::screens::CurrentScreen; /// Snapshot of application state needed to map raw terminal input. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct InputContext { pub current_screen: CurrentScreen, pub popup_open: bool, @@ -10,42 +10,3 @@ pub struct InputContext { pub kw_ops_editing: bool, pub preview_fullscreen: bool, } - -impl InputContext { - #[cfg(test)] - pub fn new(current_screen: CurrentScreen) -> Self { - Self { - current_screen, - popup_open: false, - confirm_popup_open: false, - edit_config_editing: false, - kw_ops_editing: false, - preview_fullscreen: false, - } - } - - #[cfg(test)] - pub fn with_popup_open(mut self, popup_open: bool) -> Self { - self.popup_open = popup_open; - self - } - - #[cfg(test)] - pub fn with_confirm_popup_open(mut self) -> Self { - self.popup_open = true; - self.confirm_popup_open = true; - self - } - - #[cfg(test)] - pub fn with_edit_config_editing(mut self, edit_config_editing: bool) -> Self { - self.edit_config_editing = edit_config_editing; - self - } - - #[cfg(test)] - pub fn with_kw_ops_editing(mut self, kw_ops_editing: bool) -> Self { - self.kw_ops_editing = kw_ops_editing; - self - } -} diff --git a/src/input/event.rs b/src/input/event.rs index cf18872..4319c7b 100644 --- a/src/input/event.rs +++ b/src/input/event.rs @@ -15,25 +15,14 @@ pub struct KeyInput { pub kind: KeyEventKind, } -impl KeyInput { - #[cfg(test)] - pub fn new(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Self { +impl Default for KeyInput { + fn default() -> Self { Self { - code, - modifiers, - kind, + code: KeyCode::Null, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, } } - - #[cfg(test)] - pub fn press(code: KeyCode) -> Self { - Self::new(code, KeyModifiers::NONE, KeyEventKind::Press) - } - - #[cfg(test)] - pub fn modified_press(code: KeyCode, modifiers: KeyModifiers) -> Self { - Self::new(code, modifiers, KeyEventKind::Press) - } } impl From for KeyInput { diff --git a/src/input/mapper.rs b/src/input/mapper.rs index 4ea219a..9b6fbd5 100644 --- a/src/input/mapper.rs +++ b/src/input/mapper.rs @@ -261,21 +261,35 @@ mod tests { }; fn context(current_screen: CurrentScreen) -> InputContext { - InputContext::new(current_screen) + InputContext { + current_screen, + ..Default::default() + } } fn key(code: KeyCode) -> TerminalEvent { - TerminalEvent::Key(KeyInput::press(code)) + TerminalEvent::Key(KeyInput { + code, + ..Default::default() + }) } fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> TerminalEvent { - TerminalEvent::Key(KeyInput::modified_press(code, modifiers)) + TerminalEvent::Key(KeyInput { + code, + modifiers, + ..Default::default() + }) } #[test] fn maps_escape_to_close_popup_when_popup_is_open() { let mut mapper = InputMapper::default(); - let context = context(CurrentScreen::PatchsetDetails).with_popup_open(true); + let context = InputContext { + current_screen: CurrentScreen::PatchsetDetails, + popup_open: true, + ..Default::default() + }; let event = mapper.map_terminal_event(key(KeyCode::Esc), &context); @@ -285,13 +299,22 @@ mod tests { #[test] fn maps_enter_to_confirm_popup_only_for_confirm_popups() { let mut mapper = InputMapper::default(); - let confirm = context(CurrentScreen::MailingListSelection).with_confirm_popup_open(); + let confirm = InputContext { + current_screen: CurrentScreen::MailingListSelection, + popup_open: true, + confirm_popup_open: true, + ..Default::default() + }; assert_eq!( mapper.map_terminal_event(key(KeyCode::Enter), &confirm), Some(InputEvent::ConfirmPopup) ); - let info = context(CurrentScreen::MailingListSelection).with_popup_open(true); + let info = InputContext { + current_screen: CurrentScreen::MailingListSelection, + popup_open: true, + ..Default::default() + }; assert_eq!(mapper.map_terminal_event(key(KeyCode::Enter), &info), None); } @@ -337,7 +360,11 @@ mod tests { #[test] fn maps_enter_in_edit_mode_to_stage_config_edit() { let mut mapper = InputMapper::default(); - let context = context(CurrentScreen::EditConfig).with_edit_config_editing(true); + let context = InputContext { + current_screen: CurrentScreen::EditConfig, + edit_config_editing: true, + ..Default::default() + }; let event = mapper.map_terminal_event(key(KeyCode::Enter), &context); @@ -357,7 +384,11 @@ mod tests { #[test] fn maps_arrows_to_navigate_while_editing_config_and_keeps_hl_as_text() { let mut mapper = InputMapper::default(); - let editing = context(CurrentScreen::EditConfig).with_edit_config_editing(true); + let editing = InputContext { + current_screen: CurrentScreen::EditConfig, + edit_config_editing: true, + ..Default::default() + }; assert_eq!( mapper.map_terminal_event(key(KeyCode::Left), &editing), @@ -391,11 +422,11 @@ mod tests { fn ignores_key_release_events() { let mut mapper = InputMapper::default(); let context = context(CurrentScreen::MailingListSelection); - let event = TerminalEvent::Key(KeyInput::new( - KeyCode::Esc, - KeyModifiers::NONE, - KeyEventKind::Release, - )); + let event = TerminalEvent::Key(KeyInput { + code: KeyCode::Esc, + kind: KeyEventKind::Release, + ..Default::default() + }); let mapped = mapper.map_terminal_event(event, &context); @@ -486,7 +517,10 @@ mod tests { Some(InputEvent::EditKwOpsField) ); - let editing = context.with_kw_ops_editing(true); + let editing = InputContext { + kw_ops_editing: true, + ..context + }; assert_eq!( mapper.map_terminal_event(key(KeyCode::Char('b')), &editing), Some(InputEvent::TextInput('b')) diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index 510b6bd..cf46638 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -196,7 +196,10 @@ mod tests { #[tokio::test] async fn read_event_returns_terminal_event_from_actor() { - let expected = TerminalEvent::Key(KeyInput::press(KeyCode::Char('j'))); + let expected = TerminalEvent::Key(KeyInput { + code: KeyCode::Char('j'), + ..Default::default() + }); let mut session = MockTerminalSessionApi::new(); session .expect_read_event() @@ -208,7 +211,10 @@ mod tests { assert_eq!( result, - Some(TerminalEvent::Key(KeyInput::press(KeyCode::Char('j')))) + Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Char('j'), + ..Default::default() + })) ); } From 9865d9d907f733fc9aa49355544d949d6ba9e6dc Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:33:29 -0300 Subject: [PATCH 08/61] test(terminal): drop the test-only ReadEvent message TerminalHandle::read_event and TerminalMessage::ReadEvent existed only to block on the session from tests. Event delivery already goes through TerminalHandle::poll_event and the input actor. Removed read_event_returns_terminal_event_from_actor: it only asserted that the deleted message returned the event stubbed on the session mock. The input actor tests already assert that a session key is delivered. Signed-off-by: lorenzoberts --- src/terminal/actor.rs | 40 ++-------------------------------------- src/terminal/handle.rs | 7 ------- src/terminal/messages.rs | 6 ------ 3 files changed, 2 insertions(+), 51 deletions(-) diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index cf46638..4ea0bd2 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -1,7 +1,7 @@ //! Terminal session actor: owns raw TUI I/O on a dedicated task. //! //! [`TerminalHandle`](crate::terminal::handle::TerminalHandle) exposes draw, -//! poll/read event, size, and user-I/O setup as typed messages. The session +//! poll event, size, and user-I/O setup as typed messages. The session //! implementation ([`TerminalSessionApi`](crate::terminal::session::TerminalSessionApi), //! e.g. crossterm) is moved into the actor at spawn time so no other component //! holds the terminal directly. @@ -63,14 +63,6 @@ impl TerminalActor { .and_then(|result| result); send_terminal_reply(message_name, reply, result); } - #[cfg(test)] - TerminalMessage::ReadEvent { reply } => { - let result = self - .with_session(|session| session.read_event()) - .await - .and_then(|result| result); - send_terminal_reply(message_name, reply, result); - } TerminalMessage::PollEvent { timeout, reply } => { let result = self .with_session(move |session| session.poll_event(timeout)) @@ -167,11 +159,7 @@ mod tests { use ratatui::crossterm::event::KeyCode; - use crate::{ - input::event::{KeyInput, TerminalEvent}, - terminal::messages::TerminalFrame, - terminal::session::MockTerminalSessionApi, - }; + use crate::{terminal::messages::TerminalFrame, terminal::session::MockTerminalSessionApi}; use super::*; @@ -194,30 +182,6 @@ mod tests { assert!(result.is_ok()); } - #[tokio::test] - async fn read_event_returns_terminal_event_from_actor() { - let expected = TerminalEvent::Key(KeyInput { - code: KeyCode::Char('j'), - ..Default::default() - }); - let mut session = MockTerminalSessionApi::new(); - session - .expect_read_event() - .times(1) - .returning(move || Ok(Some(expected.clone()))); - let handle = spawn_test_actor(session); - - let result = handle.read_event().await.unwrap(); - - assert_eq!( - result, - Some(TerminalEvent::Key(KeyInput { - code: KeyCode::Char('j'), - ..Default::default() - })) - ); - } - #[tokio::test] async fn sequential_requests_preserve_session() { let mut session = MockTerminalSessionApi::new(); diff --git a/src/terminal/handle.rs b/src/terminal/handle.rs index 040a533..64fe865 100644 --- a/src/terminal/handle.rs +++ b/src/terminal/handle.rs @@ -26,13 +26,6 @@ impl TerminalHandle { .await } - /// Reads the next raw terminal event, blocking until one arrives. - #[cfg(test)] - pub async fn read_event(&self) -> TerminalResult> { - self.request_result(|reply| TerminalMessage::ReadEvent { reply }) - .await - } - pub async fn poll_event(&self, timeout: Duration) -> TerminalResult> { self.request_result(|reply| TerminalMessage::PollEvent { timeout, reply }) .await diff --git a/src/terminal/messages.rs b/src/terminal/messages.rs index 7e4a9e3..404d7d0 100644 --- a/src/terminal/messages.rs +++ b/src/terminal/messages.rs @@ -22,10 +22,6 @@ pub enum TerminalMessage { frame: TerminalFrame, reply: oneshot::Sender>, }, - #[cfg(test)] - ReadEvent { - reply: oneshot::Sender>>, - }, PollEvent { timeout: Duration, reply: oneshot::Sender>>, @@ -53,8 +49,6 @@ impl TerminalMessage { pub fn name(&self) -> &'static str { match self { TerminalMessage::Draw { .. } => "Draw", - #[cfg(test)] - TerminalMessage::ReadEvent { .. } => "ReadEvent", TerminalMessage::PollEvent { .. } => "PollEvent", TerminalMessage::SetupUserIo { .. } => "SetupUserIo", TerminalMessage::TeardownUserIo { .. } => "TeardownUserIo", From a613d2166908b3aa0d58e1be48b9112a9b6f1f27 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:34:23 -0300 Subject: [PATCH 09/61] refactor(config): remove test-only accessors from config and lore state ConfigState::page_size only reread the crate-visible field, and PatchFeedIndex::get_patch only forwarded the private map. Tests read those directly. Signed-off-by: lorenzoberts --- src/config/state.rs | 5 ----- src/config/tests.rs | 2 +- src/lore/domain/patchset.rs | 7 +------ 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/config/state.rs b/src/config/state.rs index b0f7ff3..291a50c 100644 --- a/src/config/state.rs +++ b/src/config/state.rs @@ -90,11 +90,6 @@ impl ConfigState { } } - #[cfg(test)] - pub fn page_size(&self) -> usize { - self.page_size - } - fn set_page_size(&mut self, page_size: usize) { self.page_size = page_size; } diff --git a/src/config/tests.rs b/src/config/tests.rs index 33ce226..98fac8f 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -396,7 +396,7 @@ fn deserialize_config_state_with_missing_field() { let state: ConfigState = serde_json::from_value(json_data).unwrap(); - assert_eq!(state.page_size(), 30); + assert_eq!(state.page_size, 30); assert_eq!(state.max_log_age(), 500); // Missing fields fall back to the compiled-in defaults; in particular the // kw-integration apply toggle defaults to staying on the applied branch. diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index 50319b8..dd3c1cd 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -40,11 +40,6 @@ impl PatchFeedIndex { &self.representative_patch_ids } - #[cfg(test)] - pub fn get_patch(&self, id: &str) -> Option<&Patch> { - self.patches_by_id.get(id) - } - /// Process a page of [`PatchFeed`] entries, deduplicating and tracking /// which patches are "representative" (series cover or standalone patch). pub fn process_feed_page(&mut self, feed: PatchFeed) { @@ -157,7 +152,7 @@ mod tests { assert_eq!(1, idx.representative_patch_ids().len()); let id = &idx.representative_patch_ids()[0]; assert!(id.contains("1234.567-1-john@johnson.com")); - let patch = idx.get_patch(id).unwrap(); + let patch = idx.patches_by_id.get(id).unwrap(); assert_eq!("some/subsystem: Do this and that", patch.title()); assert_eq!(1, patch.version()); } From 476c8ef3428bbaf1e9db4c9466735e2e51eefd8b Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:49:04 -0300 Subject: [PATCH 10/61] refactor(kw): remove unused history, readiness and handle APIs apply_record, build_record, and latest_build_record had no production callers. History tests now read through apply_record_for_branch and build_records, which still check the stored records, overwrite, coexistence, missing files, corrupt files, and newest-across-branches. KwReadiness.kernel_image, build_record, and latest_build were written and never read outside tests. evaluate_readiness still feeds them into deploy_alone, and the tests assert that verdict. On unix the allow on KwHandle hid nothing; on non-unix `KwHandle::new` is unused until the next commit gates it. No tests removed. Signed-off-by: lorenzoberts --- src/app/actor.rs | 3 - src/app/flows/kw_ops.rs | 3 - src/app/screens/kw_ops.rs | 3 - src/app/view_model.rs | 6 -- src/kw/actor/actor_test.rs | 1 - src/kw/handle.rs | 1 - src/kw/history.rs | 185 ++++++++++++++++++------------------- src/kw/readiness.rs | 34 ++----- 8 files changed, 100 insertions(+), 136 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index e3e0458..377222f 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -576,9 +576,6 @@ mod tests { arch: Some("x86_64".to_string()), }, output_dir: None, - kernel_image: None, - build_record: None, - latest_build: None, deploy_alone: Err(crate::kw::readiness::DeployAloneRefusal::NoBuildRecord), current_branch: Some("main".to_string()), deploy_remote: Err(crate::kw::remote::RemoteRefusal::NoRemotesConfigured), diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index a597c7f..0e2f70a 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -581,9 +581,6 @@ mod tests { arch: Some("x86_64".to_string()), }, output_dir: None, - kernel_image: None, - build_record: None, - latest_build: None, deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), current_branch: Some("main".to_string()), deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), diff --git a/src/app/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index 2a7069d..6d772ff 100644 --- a/src/app/screens/kw_ops.rs +++ b/src/app/screens/kw_ops.rs @@ -177,9 +177,6 @@ mod tests { arch: Some("x86_64".to_string()), }, output_dir: None, - kernel_image: None, - build_record: None, - latest_build: None, deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), current_branch: branch.map(str::to_string), deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), diff --git a/src/app/view_model.rs b/src/app/view_model.rs index eb2a37a..de5d229 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -945,9 +945,6 @@ mod tests { arch: Some("x86_64".to_string()), }, output_dir: None, - kernel_image: None, - build_record: None, - latest_build: None, deploy_alone: Err(crate::kw::readiness::DeployAloneRefusal::NoBuildRecord), current_branch: Some("feature".to_string()), deploy_remote: Err(crate::kw::remote::RemoteRefusal::NoRemotesConfigured), @@ -993,9 +990,6 @@ mod tests { arch: Some("x86_64".to_string()), }, output_dir: None, - kernel_image: None, - build_record: None, - latest_build: None, deploy_alone: Err(crate::kw::readiness::DeployAloneRefusal::NoBuildRecord), current_branch: branch.map(str::to_string), deploy_remote: Err(crate::kw::remote::RemoteRefusal::NoRemotesConfigured), diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index 6d59ae2..080988c 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -666,7 +666,6 @@ async fn get_readiness_for_branch_looks_up_that_branch_not_head() { .unwrap(); assert_eq!(Some("master".to_string()), readiness.current_branch); - assert_eq!(None, readiness.build_record); handle.shutdown().await; } diff --git a/src/kw/handle.rs b/src/kw/handle.rs index ad6ed04..6f406bf 100644 --- a/src/kw/handle.rs +++ b/src/kw/handle.rs @@ -16,7 +16,6 @@ pub struct KwHandle { tx: mpsc::Sender, } -#[allow(dead_code)] impl KwHandle { pub fn new(tx: mpsc::Sender) -> Self { Self { tx } diff --git a/src/kw/history.rs b/src/kw/history.rs index 85d35c5..01929b3 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -70,16 +70,6 @@ pub trait KwHistoryStore: Send + Sync { /// `(message_id, kernel_tree_id)` pair. fn record_apply(&self, record: KwApplyRecord) -> Result<(), FileSystemError>; - /// Returns the apply record for the `(message_id, kernel_tree_id)` pair, - /// or `None` if it was never recorded. A missing history file is a normal - /// state, not an error. - #[allow(dead_code)] - fn apply_record( - &self, - message_id: &str, - kernel_tree_id: &str, - ) -> Result, FileSystemError>; - /// Returns the newest apply record for the tree whose applied branch /// is `branch` — the link from a build's branch back to the patchset /// it came from. A missing history file is a normal state, not an @@ -101,24 +91,6 @@ pub trait KwHistoryStore: Send + Sync { #[cfg_attr(not(unix), allow(dead_code))] fn record_build(&self, record: KwBuildRecord) -> Result<(), FileSystemError>; - /// Returns the build record for the `(kernel_tree_id, branch)` pair, or - /// `None` if it was never recorded. A missing history file is a normal - /// state, not an error. - #[allow(dead_code)] - fn build_record( - &self, - kernel_tree_id: &str, - branch: &str, - ) -> Result, FileSystemError>; - - /// Returns the chronologically newest build record for the tree, across - /// branches, or `None` if none was recorded. - #[allow(dead_code)] - fn latest_build_record( - &self, - kernel_tree_id: &str, - ) -> Result, FileSystemError>; - /// Returns the record for `(kernel_tree_id, branch)` and the newest /// record for the tree across branches from a single load of the /// history file — the pair a readiness snapshot is computed from. @@ -194,21 +166,6 @@ impl KwHistoryStore for FileKwHistoryStore { .map_err(|e| self.error_with_path(&self.apply_history_path, e)) } - fn apply_record( - &self, - message_id: &str, - kernel_tree_id: &str, - ) -> Result, FileSystemError> { - self.load_records(&self.apply_history_path) - .map(|records: ApplyRecords| { - records - .get(message_id) - .and_then(|by_tree| by_tree.get(kernel_tree_id)) - .cloned() - }) - .map_err(|e| self.error_with_path(&self.apply_history_path, e)) - } - fn apply_record_for_branch( &self, kernel_tree_id: &str, @@ -233,24 +190,6 @@ impl KwHistoryStore for FileKwHistoryStore { .map_err(|e| self.error_with_path(&self.build_history_path, e)) } - fn build_record( - &self, - kernel_tree_id: &str, - branch: &str, - ) -> Result, FileSystemError> { - self.build_records(kernel_tree_id, branch) - .map(|(record, _)| record) - } - - fn latest_build_record( - &self, - kernel_tree_id: &str, - ) -> Result, FileSystemError> { - // The branch half of the pair is unused here. - self.build_records(kernel_tree_id, "") - .map(|(_, latest)| latest) - } - fn build_records( &self, kernel_tree_id: &str, @@ -360,11 +299,15 @@ mod tests { assert_eq!( Some(record("msg-1", "mainline", "patchset-2026-08-01-17-30-00")), - store.apply_record("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-2026-08-01-17-30-00") + .unwrap() ); assert_eq!( Some(record("msg-2", "mainline", "patchset-2026-08-02-10-00-00")), - store.apply_record("msg-2", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-2026-08-02-10-00-00") + .unwrap() ); fs::remove_dir_all(&dir).unwrap(); @@ -384,7 +327,15 @@ mod tests { assert_eq!( Some(record("msg-1", "mainline", "patchset-new")), - store.apply_record("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-new") + .unwrap() + ); + assert_eq!( + None, + store + .apply_record_for_branch("mainline", "patchset-old") + .unwrap() ); fs::remove_dir_all(&dir).unwrap(); @@ -404,13 +355,22 @@ mod tests { assert_eq!( Some(record("msg-1", "mainline", "patchset-mainline")), - store.apply_record("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-mainline") + .unwrap() ); assert_eq!( Some(record("msg-1", "stable", "patchset-stable")), - store.apply_record("msg-1", "stable").unwrap() + store + .apply_record_for_branch("stable", "patchset-stable") + .unwrap() + ); + assert_eq!( + None, + store + .apply_record_for_branch("amd-gfx", "patchset-mainline") + .unwrap() ); - assert_eq!(None, store.apply_record("msg-1", "amd-gfx").unwrap()); fs::remove_dir_all(&dir).unwrap(); } @@ -420,7 +380,12 @@ mod tests { let dir = tmp_dir("missing"); let store = store_at(&dir); - assert_eq!(None, store.apply_record("msg-1", "mainline").unwrap()); + assert_eq!( + None, + store + .apply_record_for_branch("mainline", "patchset-x") + .unwrap() + ); fs::remove_dir_all(&dir).unwrap(); } @@ -563,7 +528,9 @@ mod tests { assert_eq!( Some(record("msg-1", "mainline", "patchset-x")), - store.apply_record("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-x") + .unwrap() ); fs::remove_dir_all(&dir).unwrap(); @@ -575,7 +542,9 @@ mod tests { let store = store_at(&dir); fs::write(dir.join(APPLY_HISTORY_FILENAME), b"not json").unwrap(); - let err = store.apply_record("msg-1", "mainline").unwrap_err(); + let err = store + .apply_record_for_branch("mainline", "patchset-x") + .unwrap_err(); // Errors name the file so the warning popup can point at it. assert!(err.to_string().contains(APPLY_HISTORY_FILENAME)); assert!(store @@ -623,16 +592,22 @@ mod tests { store.record_build(failed.clone()).unwrap(); // A failed attempt is stored, not dropped: deploy-alone readiness - // refuses it. + // refuses it. The newer failed record is also the tree's latest. assert_eq!( - Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), - store.build_record("mainline", "for-next").unwrap() + ( + Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), + Some(failed.clone()), + ), + store.build_records("mainline", "for-next").unwrap() ); assert_eq!( - Some(failed), - store.build_record("mainline", "patchset-x").unwrap() + (Some(failed.clone()), Some(failed.clone())), + store.build_records("mainline", "patchset-x").unwrap() + ); + assert_eq!( + (None, Some(failed)), + store.build_records("mainline", "master").unwrap() ); - assert_eq!(None, store.build_record("mainline", "master").unwrap()); fs::remove_dir_all(&dir).unwrap(); } @@ -649,9 +624,10 @@ mod tests { .record_build(build("mainline", "for-next", "2026-08-02T18:10:00Z")) .unwrap(); + let overwritten = build("mainline", "for-next", "2026-08-02T18:10:00Z"); assert_eq!( - Some(build("mainline", "for-next", "2026-08-02T18:10:00Z")), - store.build_record("mainline", "for-next").unwrap() + (Some(overwritten.clone()), Some(overwritten)), + store.build_records("mainline", "for-next").unwrap() ); fs::remove_dir_all(&dir).unwrap(); @@ -672,24 +648,29 @@ mod tests { .record_build(build("stable", "for-next", "2026-08-03T18:10:00Z")) .unwrap(); + let mainline_latest = build("mainline", "patchset-x", "2026-08-02T18:10:00Z"); assert_eq!( - Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), - store.build_record("mainline", "for-next").unwrap() + ( + Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), + Some(mainline_latest.clone()), + ), + store.build_records("mainline", "for-next").unwrap() ); assert_eq!( - Some(build("mainline", "patchset-x", "2026-08-02T18:10:00Z")), - store.build_record("mainline", "patchset-x").unwrap() + (Some(mainline_latest.clone()), Some(mainline_latest)), + store.build_records("mainline", "patchset-x").unwrap() ); + let stable = build("stable", "for-next", "2026-08-03T18:10:00Z"); assert_eq!( - Some(build("stable", "for-next", "2026-08-03T18:10:00Z")), - store.build_record("stable", "for-next").unwrap() + (Some(stable.clone()), Some(stable)), + store.build_records("stable", "for-next").unwrap() ); fs::remove_dir_all(&dir).unwrap(); } #[test] - fn latest_build_record_picks_newest_across_branches() { + fn build_records_picks_newest_across_branches() { let dir = tmp_dir("build-latest"); let store = store_at(&dir); @@ -707,11 +688,22 @@ mod tests { .record_build(build("mainline", "broken-ts", "not a timestamp")) .unwrap(); + let newest = build("mainline", "patchset-x", "2026-08-03T18:10:00Z"); + assert_eq!( + ( + Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), + Some(newest.clone()), + ), + store.build_records("mainline", "for-next").unwrap() + ); assert_eq!( - Some(build("mainline", "patchset-x", "2026-08-03T18:10:00Z")), - store.latest_build_record("mainline").unwrap() + (Some(newest.clone()), Some(newest)), + store.build_records("mainline", "patchset-x").unwrap() + ); + assert_eq!( + (None, None), + store.build_records("amd-gfx", "patchset-x").unwrap() ); - assert_eq!(None, store.latest_build_record("amd-gfx").unwrap()); fs::remove_dir_all(&dir).unwrap(); } @@ -756,8 +748,10 @@ mod tests { let dir = tmp_dir("build-missing"); let store = store_at(&dir); - assert_eq!(None, store.build_record("mainline", "for-next").unwrap()); - assert_eq!(None, store.latest_build_record("mainline").unwrap()); + assert_eq!( + (None, None), + store.build_records("mainline", "for-next").unwrap() + ); fs::remove_dir_all(&dir).unwrap(); } @@ -768,7 +762,7 @@ mod tests { let store = store_at(&dir); fs::write(dir.join(BUILD_HISTORY_FILENAME), b"not json").unwrap(); - let err = store.build_record("mainline", "for-next").unwrap_err(); + let err = store.build_records("mainline", "for-next").unwrap_err(); assert!(err.to_string().contains(BUILD_HISTORY_FILENAME)); assert!(store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) @@ -808,7 +802,10 @@ mod tests { .record_apply(record("msg-1", "mainline", "patchset-x")) .unwrap(); assert!(!dir.join(BUILD_HISTORY_FILENAME).exists()); - assert_eq!(None, store.build_record("mainline", "patchset-x").unwrap()); + assert_eq!( + (None, None), + store.build_records("mainline", "patchset-x").unwrap() + ); store .record_build(build("mainline", "patchset-x", "2026-08-01T18:10:00Z")) @@ -817,7 +814,9 @@ mod tests { assert!(dir.join(BUILD_HISTORY_FILENAME).exists()); assert_eq!( Some(record("msg-1", "mainline", "patchset-x")), - store.apply_record("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-x") + .unwrap() ); fs::remove_dir_all(&dir).unwrap(); diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index d412180..5364946 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -588,20 +588,12 @@ pub fn check_deploy_alone( /// Snapshot of tree, kw binary, and history probes used to decide whether /// a job can start, and why not. -#[allow(dead_code)] #[derive(Debug, Clone)] pub struct KwReadiness { pub kw_binary: KwBinaryProbe, pub tree: TreeReadiness, /// Active kw env's `O=` dir, if any. pub output_dir: Option, - /// Newest discoverable kernel image under the build root, if any. - pub kernel_image: Option, - /// Build record for `(kernel_tree_id, lookup_branch)`, if any. - pub build_record: Option, - /// Newest build record for the tree across branches, even when HEAD - /// has none. - pub latest_build: Option, /// `Ok(())` is a self-sufficient verdict: tree readiness is already /// conjoined in, so a caller cannot forget to check `tree` as well. pub deploy_alone: Result<(), DeployAloneRefusal>, @@ -671,9 +663,6 @@ pub fn evaluate_readiness( kw_binary, tree: tree_status, output_dir, - kernel_image, - build_record, - latest_build, deploy_alone, current_branch: { let trimmed = head_branch.trim(); @@ -1518,8 +1507,9 @@ last_line_without_newline=yes"; Arc::new(OsFileSystem), data.path().to_str().unwrap().to_string(), ); - let record = built_record(dir.path(), "patchset-x"); - history.record_build(record.clone()).unwrap(); + history + .record_build(built_record(dir.path(), "patchset-x")) + .unwrap(); let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| true); @@ -1547,9 +1537,8 @@ last_line_without_newline=yes"; }, readiness.tree ); - assert_eq!(Some(boot.join("bzImage")), readiness.kernel_image); - assert_eq!(Some(record.clone()), readiness.build_record); - assert_eq!(Some(record), readiness.latest_build); + // A matching successful record plus the image under arch/x86/boot + // is what makes deploy-alone succeed. assert_eq!(Ok(()), readiness.deploy_alone); assert_eq!(None, readiness.output_dir); assert!(readiness.kw_binary.available); @@ -1590,9 +1579,6 @@ last_line_without_newline=yes"; .unwrap(); assert_eq!(TreeReadiness::Missing, readiness.tree); - assert_eq!(None, readiness.kernel_image); - assert_eq!(None, readiness.build_record); - assert_eq!(None, readiness.latest_build); // Tree readiness is conjoined into the deploy-alone verdict, so // Ok(()) can never describe a tree that is not build-ready. assert_eq!( @@ -1637,9 +1623,6 @@ last_line_without_newline=yes"; // No build.config: arch stays None (glob fallback); no images exist. assert_eq!(TreeReadiness::Ready { arch: None }, readiness.tree); - assert_eq!(None, readiness.kernel_image); - assert_eq!(None, readiness.build_record); - assert_eq!(None, readiness.latest_build); assert_eq!( Err(DeployAloneRefusal::NoBuildRecord), readiness.deploy_alone @@ -1686,8 +1669,9 @@ last_line_without_newline=yes"; Arc::new(OsFileSystem), data.path().to_str().unwrap().to_string(), ); - let record = built_record(dir.path(), "patchset-x"); - history.record_build(record.clone()).unwrap(); + history + .record_build(built_record(dir.path(), "patchset-x")) + .unwrap(); let boot = dir.path().join("arch/x86/boot"); fs::create_dir_all(&boot).unwrap(); write_file_with_mtime(&boot.join("bzImage"), 100); @@ -1713,7 +1697,6 @@ last_line_without_newline=yes"; ) .unwrap(); assert_eq!(Some("master".to_string()), on_head.current_branch); - assert_eq!(None, on_head.build_record); assert_eq!( Err(DeployAloneRefusal::HeadMismatch { recorded: "patchset-x".to_string(), @@ -1735,7 +1718,6 @@ last_line_without_newline=yes"; .unwrap(); // HEAD is still master; deploy-alone is judged against the typed branch. assert_eq!(Some("master".to_string()), for_typed.current_branch); - assert_eq!(Some(record), for_typed.build_record); assert_eq!(Ok(()), for_typed.deploy_alone); } From a03c126e94f885ec866c52dd22c0bc51873733db Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 17:50:24 -0300 Subject: [PATCH 11/61] refactor(lore): remove unused cache policy and feed target list CachePolicy had no implementors. PatchFeedIndex stored target_list and never read it, so new() no longer takes that string. The empty-state test still checks the offset and representative ids; the list-name assertion only read the removed getter. No tests removed. Signed-off-by: lorenzoberts --- src/lore/application/cache.rs | 11 ----------- src/lore/application/service.rs | 6 +++--- src/lore/domain/patchset.rs | 25 ++++++++----------------- 3 files changed, 11 insertions(+), 31 deletions(-) diff --git a/src/lore/application/cache.rs b/src/lore/application/cache.rs index 591b1c7..b42078f 100644 --- a/src/lore/application/cache.rs +++ b/src/lore/application/cache.rs @@ -51,17 +51,6 @@ impl Default for CacheTtl { } } -// ── Generic cache policy trait ──────────────────────────────────────────────── - -/// Uniform cache interface (`get`, `put`, `invalidate`, `clear`) for Lore cache stores. -#[allow(dead_code)] -pub trait CachePolicy { - fn get(&self, key: &K) -> Option<&V>; - fn put(&mut self, key: K, value: V); - fn invalidate(&mut self, key: &K); - fn clear(&mut self); -} - // ── Mailing lists cache ─────────────────────────────────────────────────────── pub struct MailingListsCacheEntry { diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index 08454f9..acd0dd6 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -206,7 +206,7 @@ impl LoreService { self.cache .feeds .entry(target_list.to_string()) - .or_insert_with(|| FeedCacheEntry::new(PatchFeedIndex::new(target_list.to_string()))); + .or_insert_with(|| FeedCacheEntry::new(PatchFeedIndex::new())); // Clone the Arc so the borrow on `self.feed_gateway` doesn't conflict // with the mutable borrow on `self.cache.feeds`. @@ -758,7 +758,7 @@ mod tests { let xml = fs::read_to_string(src).unwrap(); parse_patch_feed(&xml).unwrap() }; - let mut index = PatchFeedIndex::new(target.to_string()); + let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); svc.cache .feeds @@ -797,7 +797,7 @@ mod tests { let xml = fs::read_to_string(src).unwrap(); crate::lore::infrastructure::parsers::parse_patch_feed(&xml).unwrap() }; - let mut index = PatchFeedIndex::new(target.to_string()); + let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); svc.cache .feeds diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index dd3c1cd..112b215 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -8,8 +8,6 @@ const LORE_PAGE_SIZE: usize = 200; /// /// Replaces the state that was previously mixed into [`LoreSession`]. pub struct PatchFeedIndex { - #[allow(dead_code)] - target_list: String, next_offset: usize, representative_patch_ids: Vec, patches_by_id: HashMap, @@ -17,9 +15,8 @@ pub struct PatchFeedIndex { } impl PatchFeedIndex { - pub fn new(target_list: String) -> Self { + pub fn new() -> Self { PatchFeedIndex { - target_list, next_offset: 0, representative_patch_ids: Vec::new(), patches_by_id: HashMap::new(), @@ -27,11 +24,6 @@ impl PatchFeedIndex { } } - #[allow(dead_code)] - pub fn target_list(&self) -> &str { - &self.target_list - } - pub fn next_offset(&self) -> usize { self.next_offset } @@ -134,15 +126,14 @@ mod tests { #[test] fn new_starts_with_empty_state() { - let idx = PatchFeedIndex::new("linux-kernel".to_string()); - assert_eq!("linux-kernel", idx.target_list()); + let idx = PatchFeedIndex::new(); assert_eq!(0, idx.next_offset()); assert!(idx.representative_patch_ids().is_empty()); } #[test] fn process_feed_page_extracts_representative_patch() { - let mut idx = PatchFeedIndex::new("some-list".to_string()); + let mut idx = PatchFeedIndex::new(); let feed = feed_from_file( "test_samples/lore_session/process_representative_patch/patch_feed_sample_1.xml", ); @@ -159,7 +150,7 @@ mod tests { #[test] fn process_feed_page_extracts_multiple_representative_patches() { - let mut idx = PatchFeedIndex::new("some-list".to_string()); + let mut idx = PatchFeedIndex::new(); let feed = feed_from_file( "test_samples/lore_session/process_representative_patch/patch_feed_sample_2.xml", ); @@ -171,7 +162,7 @@ mod tests { #[test] fn process_feed_page_deduplicates() { - let mut idx = PatchFeedIndex::new("some-list".to_string()); + let mut idx = PatchFeedIndex::new(); let feed = feed_from_file( "test_samples/lore_session/process_representative_patch/patch_feed_sample_1.xml", ); @@ -183,7 +174,7 @@ mod tests { #[test] fn advance_offset_increments_by_page_size() { - let mut idx = PatchFeedIndex::new("list".to_string()); + let mut idx = PatchFeedIndex::new(); assert_eq!(0, idx.next_offset()); idx.advance_offset(); assert_eq!(200, idx.next_offset()); @@ -193,13 +184,13 @@ mod tests { #[test] fn get_page_returns_none_when_empty() { - let idx = PatchFeedIndex::new("list".to_string()); + let idx = PatchFeedIndex::new(); assert!(idx.get_page(10, 1).is_none()); } #[test] fn get_page_returns_correct_patches() { - let mut idx = PatchFeedIndex::new("some-list".to_string()); + let mut idx = PatchFeedIndex::new(); let feed = feed_from_file( "test_samples/lore_session/process_representative_patch/patch_feed_sample_2.xml", ); From 53b0cbde6c38e74098ca8d489842f4822c82fa7f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 18:07:18 -0300 Subject: [PATCH 12/61] refactor(kw): gate kw modules on unix instead of allowing dead code log_scan is compiled only on Unix, with the actor. Functions whose only production caller is that actor are cfg(unix) too, including build history, the image and deploy probes, and remote.config parsing. FileSystemTrait::read_dir and KwHandle::new follow the same gate. The UI still names several types on every platform, so those stay, with #[cfg_attr(not(unix), expect(dead_code))] on the item the actor alone fills in: - KwError and KwStartError: variants only the actor constructs - DeployOptions, StartRequest, and KwMessage: fields only the actor reads - KwJobKind, KwPhase, and KwJobStatus: variants only the actor constructs - TreeReadiness, DeployAloneRefusal, and BootOnceState - RemoteRefusal No tests removed. Signed-off-by: lorenzoberts --- src/infrastructure/file_system/mod.rs | 5 ++- src/infrastructure/file_system/trait.rs | 9 +++-- src/kw/argv.rs | 4 --- src/kw/errors.rs | 8 +++-- src/kw/handle.rs | 1 + src/kw/history.rs | 21 ++++++----- src/kw/log_scan.rs | 3 -- src/kw/messages.rs | 7 ++-- src/kw/mod.rs | 1 + src/kw/readiness.rs | 48 +++++++++++++++++-------- src/kw/remote.rs | 18 +++++++--- src/kw/status.rs | 8 ++--- src/main.rs | 4 ++- 13 files changed, 85 insertions(+), 52 deletions(-) diff --git a/src/infrastructure/file_system/mod.rs b/src/infrastructure/file_system/mod.rs index 288f333..4ec91ad 100644 --- a/src/infrastructure/file_system/mod.rs +++ b/src/infrastructure/file_system/mod.rs @@ -7,10 +7,12 @@ pub use r#trait::{FileSystemError, FileSystemTrait}; #[cfg(test)] pub use r#trait::MockFileSystemTrait; +#[cfg(unix)] +use std::path::PathBuf; use std::{ fs::{self, File}, io::{self, BufReader, Read, Seek, SeekFrom}, - path::{Path, PathBuf}, + path::Path, }; #[cfg(test)] @@ -43,6 +45,7 @@ impl FileSystemTrait for OsFileSystem { path.is_dir() } + #[cfg(unix)] fn read_dir(&self, path: &Path) -> Result, FileSystemError> { let mut entries = fs::read_dir(path)? .map(|entry| entry.map(|e| e.path())) diff --git a/src/infrastructure/file_system/trait.rs b/src/infrastructure/file_system/trait.rs index 03ab2c3..daf86c0 100644 --- a/src/infrastructure/file_system/trait.rs +++ b/src/infrastructure/file_system/trait.rs @@ -1,11 +1,9 @@ use mockall::automock; use thiserror::Error; -use std::{ - fs::Metadata, - io, - path::{Path, PathBuf}, -}; +#[cfg(unix)] +use std::path::PathBuf; +use std::{fs::Metadata, io, path::Path}; #[derive(Debug, Error)] pub enum FileSystemError { @@ -24,6 +22,7 @@ pub trait FileSystemTrait: Send + Sync { /// Returns the immediate children of directory `path` as full paths, /// sorted for determinism. Entry kind and metadata are queried /// separately via `is_dir`/`is_file`/`metadata`. + #[cfg(unix)] fn read_dir(&self, path: &Path) -> Result, FileSystemError>; fn rename(&self, from: &Path, to: &Path) -> Result<(), FileSystemError>; fn create_writer(&self, path: &Path) -> Result, FileSystemError>; diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 6b398f9..343e34e 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -1,9 +1,6 @@ //! kw argv construction: patch-hub's base command lines plus the user //! extra-args merge in which reserved options always win. -// Production caller is the unix-only actor. -#![cfg_attr(not(unix), allow(dead_code))] - /// A CLI option patch-hub controls: user-supplied extra args that set it /// are stripped, so the occurrence on patch-hub's own base argv wins. pub struct ReservedOption { @@ -119,7 +116,6 @@ pub fn build_argv(extra_args: &[String]) -> Vec { /// so build-only extras (shared KwOps field) cannot fail kw deploy's /// getopt. `--force` is omitted entirely when `force` is false rather /// than passing a no-op, because kw has no `--no-force`. -#[cfg_attr(not(unix), allow(dead_code))] pub fn deploy_argv( endpoint: &str, reboot: bool, diff --git a/src/kw/errors.rs b/src/kw/errors.rs index cfedc71..212d26d 100644 --- a/src/kw/errors.rs +++ b/src/kw/errors.rs @@ -1,6 +1,3 @@ -// The actor that constructs/reads these is unix-only. -#![cfg_attr(not(unix), allow(dead_code))] - use thiserror::Error; #[cfg(unix)] @@ -13,6 +10,7 @@ use crate::{ }, }; +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Error)] pub enum KwError { #[error("kw actor unavailable: {0}")] @@ -40,6 +38,7 @@ pub enum KwError { /// Accept/refuse verdict for `Start*` messages. The reply is always /// immediate: an accepted job keeps running inside the actor after the /// caller has been answered. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Error)] pub enum KwStartError { #[error("kw actor unavailable: {0}")] @@ -76,6 +75,7 @@ pub enum KwStartError { /// Shared git-state refusal for the start and restore paths, converted /// into the public error each path reports. +#[cfg(unix)] #[derive(Debug)] pub(crate) enum TreeGitError { DirtyWorktree, @@ -83,6 +83,7 @@ pub(crate) enum TreeGitError { Switch(String), } +#[cfg(unix)] impl From for KwStartError { fn from(error: TreeGitError) -> Self { match error { @@ -93,6 +94,7 @@ impl From for KwStartError { } } +#[cfg(unix)] impl From for KwError { fn from(error: TreeGitError) -> Self { match error { diff --git a/src/kw/handle.rs b/src/kw/handle.rs index 6f406bf..fe06fff 100644 --- a/src/kw/handle.rs +++ b/src/kw/handle.rs @@ -17,6 +17,7 @@ pub struct KwHandle { } impl KwHandle { + #[cfg(unix)] pub fn new(tx: mpsc::Sender) -> Self { Self { tx } } diff --git a/src/kw/history.rs b/src/kw/history.rs index 01929b3..337b7dc 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -13,6 +13,7 @@ use std::{collections::HashMap, io, path::Path, sync::Arc}; use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait, JsonUtils}; pub const APPLY_HISTORY_FILENAME: &str = "kw_apply_history.json"; +#[cfg(unix)] pub const BUILD_HISTORY_FILENAME: &str = "kw_build_history.json"; /// One recorded `git am` application of a lore patchset to a kernel tree. @@ -62,6 +63,7 @@ type ApplyRecords = HashMap>; /// kernel tree id → branch → record: building several branches of the same /// tree keeps one record per branch, so a failed build on one branch does /// not clobber another branch's successful record. +#[cfg(unix)] type BuildRecords = HashMap>; #[automock] @@ -75,9 +77,7 @@ pub trait KwHistoryStore: Send + Sync { /// it came from. A missing history file is a normal state, not an /// error. Records with unparseable `applied_at` values sort oldest, /// same convention as the build records. - // The only production caller is the unix-only actor's build-record - // writer. - #[cfg_attr(not(unix), allow(dead_code))] + #[cfg(unix)] fn apply_record_for_branch( &self, kernel_tree_id: &str, @@ -86,14 +86,13 @@ pub trait KwHistoryStore: Send + Sync { /// Inserts or replaces the build record for the record's /// `(kernel_tree_id, branch)` pair. - // The only production caller is the unix-only actor's build-record - // writer. - #[cfg_attr(not(unix), allow(dead_code))] + #[cfg(unix)] fn record_build(&self, record: KwBuildRecord) -> Result<(), FileSystemError>; /// Returns the record for `(kernel_tree_id, branch)` and the newest /// record for the tree across branches from a single load of the /// history file — the pair a readiness snapshot is computed from. + #[cfg(unix)] fn build_records( &self, kernel_tree_id: &str, @@ -104,16 +103,18 @@ pub trait KwHistoryStore: Send + Sync { pub struct FileKwHistoryStore { fs: Arc, apply_history_path: String, + #[cfg(unix)] build_history_path: String, } impl FileKwHistoryStore { - /// Creates a store keeping both history files ([`APPLY_HISTORY_FILENAME`] - /// and [`BUILD_HISTORY_FILENAME`]) directly under `data_dir`. + /// Creates a store keeping `kw_apply_history.json` under `data_dir`. + /// On Unix it also keeps `kw_build_history.json` there. pub fn new(fs: Arc, data_dir: String) -> Self { FileKwHistoryStore { fs, apply_history_path: format!("{data_dir}/{APPLY_HISTORY_FILENAME}"), + #[cfg(unix)] build_history_path: format!("{data_dir}/{BUILD_HISTORY_FILENAME}"), } } @@ -144,6 +145,7 @@ impl FileKwHistoryStore { JsonUtils::atomic_write_json(&*self.fs, &records, &self.apply_history_path) } + #[cfg(unix)] fn store_build_record(&self, record: KwBuildRecord) -> Result<(), FileSystemError> { let mut records: BuildRecords = self.load_records(&self.build_history_path)?; records @@ -166,6 +168,7 @@ impl KwHistoryStore for FileKwHistoryStore { .map_err(|e| self.error_with_path(&self.apply_history_path, e)) } + #[cfg(unix)] fn apply_record_for_branch( &self, kernel_tree_id: &str, @@ -185,11 +188,13 @@ impl KwHistoryStore for FileKwHistoryStore { .map_err(|e| self.error_with_path(&self.apply_history_path, e)) } + #[cfg(unix)] fn record_build(&self, record: KwBuildRecord) -> Result<(), FileSystemError> { self.store_build_record(record) .map_err(|e| self.error_with_path(&self.build_history_path, e)) } + #[cfg(unix)] fn build_records( &self, kernel_tree_id: &str, diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index bc2fc69..b310cf4 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -4,9 +4,6 @@ //! //! Pure functions over the log text; the actor reads the file. -// The only production caller is the unix-only actor. -#![cfg_attr(not(unix), allow(dead_code))] - /// Longer lines (deep include paths, long make targets) are cut so one /// finding stays a single KwOps row. const MAX_LINE_CHARS: usize = 200; diff --git a/src/kw/messages.rs b/src/kw/messages.rs index efc31d0..5582c3b 100644 --- a/src/kw/messages.rs +++ b/src/kw/messages.rs @@ -1,6 +1,3 @@ -// The actor that reads these fields is unix-only. -#![cfg_attr(not(unix), allow(dead_code))] - use tokio::sync::{oneshot, watch}; use crate::{ @@ -15,6 +12,7 @@ use crate::{ /// Deploy knobs the actor injects onto `kw deploy`. Build-only starts /// leave [`StartRequest::deploy`] unset. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone)] pub struct DeployOptions { pub reboot: bool, @@ -27,6 +25,7 @@ pub struct DeployOptions { /// Everything the actor needs to start a job. The tree context is resolved /// by the caller from its config snapshot, keeping KwActor decoupled from /// ConfigActor. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone)] pub struct StartRequest { pub kernel_tree_id: String, @@ -44,6 +43,7 @@ pub struct StartRequest { pub deploy: Option, } +#[cfg_attr(not(unix), expect(dead_code))] pub enum KwMessage { RecordApply { record: KwApplyRecord, @@ -93,6 +93,7 @@ pub enum KwMessage { } impl KwMessage { + #[cfg(unix)] pub fn name(&self) -> &'static str { match self { KwMessage::RecordApply { .. } => "RecordApply", diff --git a/src/kw/mod.rs b/src/kw/mod.rs index f6b2176..c8faa7f 100644 --- a/src/kw/mod.rs +++ b/src/kw/mod.rs @@ -7,6 +7,7 @@ pub mod argv; pub mod errors; pub mod handle; pub mod history; +#[cfg(unix)] pub mod log_scan; pub mod messages; pub mod readiness; diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 5364946..dd14b95 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -11,26 +11,32 @@ //! injected infrastructure traits; KwActor composes them into the //! `GetReadiness` snapshot. +#[cfg(unix)] use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use thiserror::Error; -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - time::SystemTime, -}; +#[cfg(unix)] +use std::collections::HashMap; +#[cfg(unix)] +use std::path::Path; +use std::path::PathBuf; +#[cfg(unix)] +use std::time::SystemTime; +#[cfg(unix)] +use crate::infrastructure::file_system::FileSystemTrait; use crate::infrastructure::{ env::{EnvError, EnvTrait}, - file_system::{FileSystemError, FileSystemTrait}, + file_system::FileSystemError, shell::{ShellCommand, ShellTrait}, }; +#[cfg(unix)] +use crate::kw::remote; +use crate::kw::remote::{KwRemote, RemoteRefusal}; +#[cfg(unix)] use crate::{ config::KernelTree, - kw::{ - history::{KwBuildRecord, KwHistoryStore}, - remote::{self, KwRemote, RemoteRefusal}, - }, + kw::history::{KwBuildRecord, KwHistoryStore}, }; /// Errors from readiness probes for states where "absent" is not a normal @@ -44,6 +50,7 @@ pub enum KwReadinessError { } /// Readiness of a configured kernel tree for kw operations. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum TreeReadiness { /// Kernel root, kw-initialized, with a `.config`. `arch` is the literal @@ -74,6 +81,7 @@ pub enum TreeReadiness { /// last `#` on is stripped as a trailing comment, the key has all /// whitespace removed, and the value is trimmed. Lines without `=` are /// ignored, and a final line without a trailing newline still counts. +#[cfg(unix)] pub fn parse_kw_config(content: &str) -> HashMap { let mut entries = HashMap::new(); for line in content.lines() { @@ -96,6 +104,7 @@ pub fn parse_kw_config(content: &str) -> HashMap { /// Mirrors kw's `is_kernel_root`: the same files and directories kw checks /// (also the set `get_maintainer.pl` relies on). `MAINTAINERS` is checked /// with `exists` because kw uses `-e` on it and `-f` on the other files. +#[cfg(unix)] pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { const FILES: [&str; 5] = ["COPYING", "CREDITS", "Kbuild", "Makefile", "README"]; const DIRS: [&str; 10] = [ @@ -121,6 +130,7 @@ pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { /// refuses to activate an env while an in-tree `.config` exists /// (`kw_env.sh::validate_env_before_switch`), so with an env active the /// `.config` lives only at the env's `O=` dir. +#[cfg(unix)] pub fn probe_tree( fs: &dyn FileSystemTrait, tree_path: &Path, @@ -157,6 +167,7 @@ pub fn probe_tree( /// the caller falls back to globbing `arch/*/boot/`, a deliberate /// divergence from kw's merged-config fallback (see /// [`find_newest_kernel_image`]). +#[cfg(unix)] pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { let content = fs .read_to_string(&tree_path.join(".kw").join("build.config")) @@ -189,6 +200,7 @@ pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option Option { fs.read_dir(boot_dir) .ok()? @@ -289,6 +303,7 @@ fn newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option .map(|(_, entry)| entry) } +#[cfg(unix)] fn image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { fs.metadata(path) .and_then(|meta| meta.modified().map_err(FileSystemError::from)) @@ -299,9 +314,7 @@ fn image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { /// `/include/config/kernel.release`, the file a kernel build /// generates — cheaper than re-running `make kernelrelease`, and `None` /// when the build never produced one (or produced an empty one). -// The only production caller is the unix-only actor's build-record -// writer. -#[cfg_attr(not(unix), allow(dead_code))] +#[cfg(unix)] pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option { let release = fs .read_to_string( @@ -426,6 +439,7 @@ impl std::fmt::Display for TreeReadiness { /// Why a deploy-without-build was refused. Each variant's message is the /// actionable explanation. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum DeployAloneRefusal { #[error("the kernel tree is not ready: {0}")] @@ -464,6 +478,7 @@ pub enum DeployAloneRefusal { /// value, including a missing key, leaves the option on. [`Unknown`] is /// therefore a confirm-to-proceed gate, same as [`On`]: patch-hub cannot /// pass a CLI off-switch. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BootOnceState { Off, @@ -476,6 +491,7 @@ pub enum BootOnceState { /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess /// at the home copy. A readable tree file that simply omits the key still /// falls through, matching kw's merged-config lookup. +#[cfg(unix)] pub fn probe_boot_once( fs: &dyn FileSystemTrait, env: &dyn EnvTrait, @@ -501,6 +517,7 @@ pub fn probe_boot_once( } } +#[cfg(unix)] fn boot_once_from_file(fs: &dyn FileSystemTrait, path: &Path) -> Result, ()> { let content = fs.read_to_string(path).map_err(|_| ())?; Ok(parse_kw_config(&content) @@ -515,6 +532,7 @@ fn boot_once_from_file(fs: &dyn FileSystemTrait, path: &Path) -> Result`. A set-but-empty /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG /// spec. +#[cfg(unix)] fn xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { let config_home = match env.var("XDG_CONFIG_HOME") { Ok(xdg) if !xdg.is_empty() => xdg, @@ -537,6 +555,7 @@ fn xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { /// about the tree's *current* state. [`evaluate_readiness`] conjoins /// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over /// calling this directly. +#[cfg(unix)] pub fn check_deploy_alone( record: Option<&KwBuildRecord>, latest: Option<&KwBuildRecord>, @@ -612,8 +631,7 @@ pub struct KwReadiness { /// `for_branch`, when set, is the branch deploy-alone should be judged /// against (the branch typed on KwOps). `current_branch` still reports /// the real HEAD so the UI can show both. -// The only caller is the unix-only actor's GetReadiness. -#[cfg_attr(not(unix), allow(dead_code))] +#[cfg(unix)] pub fn evaluate_readiness( fs: &dyn FileSystemTrait, env: &dyn EnvTrait, diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 24a0c22..f58793d 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -11,16 +11,13 @@ //! file is authoritative even when empty or unreadable — falling through //! to the home copy would silently deploy to a different machine than the //! tree is configured for. -//! -//! Production callers are readiness probes and the unix-only deploy start -//! path. - -#![cfg_attr(not(unix), allow(dead_code))] +#[cfg(unix)] use std::path::{Path, PathBuf}; use thiserror::Error; +#[cfg(unix)] use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; /// A Host stanza from `remote.config` that has a usable Hostname. @@ -45,6 +42,7 @@ impl KwRemote { /// Why a deploy remote could not be resolved. Each variant's message is /// the actionable explanation. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RemoteRefusal { #[error( @@ -66,6 +64,7 @@ pub enum RemoteRefusal { /// Parsed contents of a `remote.config` file. Incomplete Host stanzas /// (no Hostname, or an unparseable Port) are dropped rather than guessed. +#[cfg(unix)] #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ParsedRemoteConfig { pub default: Option, @@ -78,6 +77,7 @@ pub struct ParsedRemoteConfig { /// `Port`, and `User` are read case-insensitively; `IdentityFile` and /// other keys are tolerated and ignored. Port defaults to 22 when unset. /// Duplicate Host names keep the last complete stanza. +#[cfg(unix)] pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { let mut parsed = ParsedRemoteConfig::default(); let mut current: Option = None; @@ -135,6 +135,7 @@ pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { /// Picks the deploy remote from a parsed file: the `#kw-default=` Host /// when set, otherwise the only Host. Multiple hosts with no default, or /// a default that names no complete Host, are refusals. +#[cfg(unix)] pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result { if let Some(name) = parsed.default.as_deref() { return parsed @@ -155,6 +156,7 @@ pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result Result { let content = fs .read_to_string(path) @@ -183,6 +186,7 @@ fn remote_from_file(fs: &dyn FileSystemTrait, path: &Path) -> Result Option { let config_home = match env.var("XDG_CONFIG_HOME") { Ok(xdg) if !xdg.is_empty() => xdg, @@ -191,6 +195,7 @@ fn xdg_kw_remote_config(env: &dyn EnvTrait) -> Option { Some(Path::new(&config_home).join("kw").join("remote.config")) } +#[cfg(unix)] struct HostBuilder { name: String, hostname: Option, @@ -198,6 +203,7 @@ struct HostBuilder { user: Option, } +#[cfg(unix)] impl HostBuilder { fn new(name: &str) -> Self { Self { @@ -224,6 +230,7 @@ impl HostBuilder { } } +#[cfg(unix)] fn split_keyword(line: &str) -> Option<(&str, &str)> { let keyword_end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); let keyword = &line[..keyword_end]; @@ -233,6 +240,7 @@ fn split_keyword(line: &str) -> Option<(&str, &str)> { Some((keyword, line[keyword_end..].trim())) } +#[cfg(unix)] fn upsert_host(hosts: &mut Vec, host: KwRemote) { if let Some(existing) = hosts.iter_mut().find(|entry| entry.name == host.name) { *existing = host; diff --git a/src/kw/status.rs b/src/kw/status.rs index 26eab61..2bcc18e 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -3,11 +3,9 @@ //! `AppState` never owns job state; it polls (`GetStatus`) or watches //! (`WatchStatus`) these snapshots and projects them into the view model. -// The actor that constructs/reads these is unix-only. -#![cfg_attr(not(unix), allow(dead_code))] - use std::path::PathBuf; +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KwJobKind { Build, @@ -16,12 +14,14 @@ pub enum KwJobKind { } /// Running phase of a job. `BuildThenDeploy` jobs pass through both. +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KwPhase { Building, Deploying, } +#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum KwJobStatus { Idle, @@ -67,6 +67,7 @@ pub struct KwStatusSnapshot { } impl KwStatusSnapshot { + #[cfg(unix)] pub fn idle() -> Self { Self { job: KwJobStatus::Idle, @@ -107,7 +108,6 @@ impl KwJobStatus { /// Unknown codes return `None` so the UI can still show the raw number. /// 68 can still surface with `--force`: force skips the prompt, not the /// initramfs errors. -#[cfg_attr(not(unix), allow(dead_code))] pub fn deploy_exit_hint(code: i32) -> Option<&'static str> { Some(match code { 2 => "kernel image not found", diff --git a/src/main.rs b/src/main.rs index 97a6dfe..33331f9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,9 @@ use lore::{ }, }; use render::{actor::RenderActor, ShellRenderService}; -use std::{ops::ControlFlow, path::Path, sync::Arc}; +#[cfg(unix)] +use std::path::Path; +use std::{ops::ControlFlow, sync::Arc}; use terminal::{actor::TerminalActor, session::CrosstermTerminalSession}; use tokio::sync::mpsc; use tracing::{event, Level}; From 822c87cd99f3d9d81483eadbbb61d18178bf0b60 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 18:10:39 -0300 Subject: [PATCH 13/61] chore(lints): drop crate-wide too-many-arguments and unused-imports allows Config callers now import the re-exports from crate::config, so the module-wide unused_imports allow is unnecessary. resolve_config_path stays on the repository module; nothing else named the re-export. #[expect(clippy::too_many_arguments)] is on five functions whose arguments are independent inputs. Folding them into a struct would only exist to be unpacked at the call: - App::new - LoreService::new - evaluate_readiness - prepare_deploy_blocking - app_with_details_and_kw No tests removed. Signed-off-by: lorenzoberts --- Cargo.toml | 1 - src/app/integration_tests/patchset_actions.rs | 2 +- src/app/mod.rs | 1 + src/config/actor.rs | 5 ++--- src/config/mod.rs | 3 +-- src/config/service.rs | 7 ++++--- src/config/state.rs | 2 +- src/kw/actor/mod.rs | 1 + src/kw/readiness.rs | 1 + src/lore/application/service.rs | 1 + 10 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 16d1596..74e6fda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ warnings = "deny" unconditional_recursion = "deny" [lints.clippy] -too-many-arguments = "allow" map_unwrap_or = "deny" [workspace] diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index 881d1b8..39d97f3 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -489,7 +489,7 @@ fn app_with_details( /// Variant of [`app_with_details`] whose kw actor gets its own mocks, /// process double, and log dir, for tests that start jobs through the /// app's kw handle. -#[allow(clippy::too_many_arguments)] +#[expect(clippy::too_many_arguments)] fn app_with_details_and_kw( fs: MockFileSystemTrait, shell: MockShellTrait, diff --git a/src/app/mod.rs b/src/app/mod.rs index 0aa9eee..4e178c9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -109,6 +109,7 @@ impl App { /// # Returns /// /// `App` instance with loading configurations and app data. + #[expect(clippy::too_many_arguments)] pub fn new( config: ConfigSnapshot, config_handle: ConfigHandle, diff --git a/src/config/actor.rs b/src/config/actor.rs index 2eb64bb..4b1ddba 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -14,10 +14,9 @@ use crate::{ config::{ handle::ConfigHandle, messages::{ConfigMessage, ConfigResult}, - repository::{ConfigRepository, JsonConfigRepository}, + normalize_derived_paths, service::{ensure_directories, validate_update}, - state::{normalize_derived_paths, ConfigState}, - ConfigSnapshot, ConfigUpdateDraft, + ConfigRepository, ConfigSnapshot, ConfigState, ConfigUpdateDraft, JsonConfigRepository, }, infrastructure::file_system::FileSystemTrait, }; diff --git a/src/config/mod.rs b/src/config/mod.rs index f70e22a..4633c12 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,5 +1,4 @@ //! Configuration bounded context: state, persistence, bootstrap, and snapshots. -#![allow(unused_imports)] // `pub use` re-exports are the public API of this module pub mod actor; mod env_overrides; @@ -15,7 +14,7 @@ mod update; pub use actor::ConfigActor; pub use errors::ConfigError; pub use handle::ConfigHandle; -pub use repository::{resolve_config_path, ConfigRepository, JsonConfigRepository}; +pub use repository::{ConfigRepository, JsonConfigRepository}; pub(crate) use service::bootstrap_parts; pub use state::{normalize_derived_paths, ConfigSnapshot, ConfigState, KernelTree}; pub use update::{ConfigUpdateDraft, ValidatedConfigUpdate}; diff --git a/src/config/service.rs b/src/config/service.rs index cbf38e9..7c5ae9c 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -5,9 +5,10 @@ use serde_json::from_str; use crate::config::env_overrides; use crate::config::errors::ConfigError; use crate::config::parsing::{parse_cover_renderer, parse_patch_renderer}; -use crate::config::repository::{ConfigRepository, JsonConfigRepository}; -use crate::config::state::{normalize_derived_paths, ConfigState}; -use crate::config::update::{ConfigUpdateDraft, ValidatedConfigUpdate}; +use crate::config::{ + normalize_derived_paths, ConfigRepository, ConfigState, ConfigUpdateDraft, + JsonConfigRepository, ValidatedConfigUpdate, +}; use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; /// Loads file or defaults, saves, applies env overrides, normalizes paths, and ensures directories. diff --git a/src/config/state.rs b/src/config/state.rs index 291a50c..47c7cf0 100644 --- a/src/config/state.rs +++ b/src/config/state.rs @@ -6,7 +6,7 @@ use std::{ env, }; -use crate::config::update::ValidatedConfigUpdate; +use crate::config::ValidatedConfigUpdate; use crate::infrastructure::env::EnvTrait; use crate::render_prefs::{CoverRenderer, PatchRenderer}; diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 734a9da..923277e 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -956,6 +956,7 @@ impl KwActor { /// Deploy gates that must not run on the actor task: remote.config, /// history JSON, image globs, and boot-once files. Record match runs /// before the boot-once confirm so a missing build refuses cheaper. +#[expect(clippy::too_many_arguments)] fn prepare_deploy_blocking( kind: KwJobKind, request: &StartRequest, diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index dd14b95..e60d112 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -632,6 +632,7 @@ pub struct KwReadiness { /// against (the branch typed on KwOps). `current_branch` still reports /// the real HEAD so the UI can show both. #[cfg(unix)] +#[expect(clippy::too_many_arguments)] pub fn evaluate_readiness( fs: &dyn FileSystemTrait, env: &dyn EnvTrait, diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index acd0dd6..d916100 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -50,6 +50,7 @@ pub struct LoreService { } impl LoreService { + #[expect(clippy::too_many_arguments)] pub fn new( lists_gateway: Arc, feed_gateway: Arc, From 161103cb41e551da72ba50cf72fbbd8d07e59e36 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 18:41:22 -0300 Subject: [PATCH 14/61] refactor(kw): move kw data types into kw/models Signed-off-by: lorenzoberts --- src/kw/actor/mod.rs | 63 +------------- src/kw/history.rs | 45 +--------- src/kw/mod.rs | 1 + src/kw/models/history.rs | 41 +++++++++ src/kw/models/job.rs | 72 ++++++++++++++++ src/kw/models/mod.rs | 5 ++ src/kw/models/readiness.rs | 163 +++++++++++++++++++++++++++++++++++ src/kw/models/remote.rs | 41 +++++++++ src/kw/readiness.rs | 169 ++----------------------------------- src/kw/remote.rs | 44 +--------- 10 files changed, 340 insertions(+), 304 deletions(-) create mode 100644 src/kw/models/history.rs create mode 100644 src/kw/models/job.rs create mode 100644 src/kw/models/mod.rs create mode 100644 src/kw/models/readiness.rs create mode 100644 src/kw/models/remote.rs diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 923277e..51bc29c 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -40,8 +40,9 @@ use crate::{ history::{KwBuildRecord, KwHistoryStore}, log_scan, messages::{DeployOptions, KwMessage, StartRequest}, + models::job::{JobDeploy, JobEvent, JobOutcome, JobState, RestoreContext}, readiness::{self, BootOnceState, KwReadiness, KwVersionCheck, TreeReadiness}, - remote::{self, KwRemote}, + remote, status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, }, }; @@ -56,66 +57,6 @@ pub const DEFAULT_KW_CHANNEL_SIZE: usize = 16; const TERM_GRACE: Duration = Duration::from_secs(3); const KILL_GRACE: Duration = Duration::from_secs(2); -/// How a job's process ended, as observed by the detached task that owns -/// the process handle. -enum JobOutcome { - Exited(ExitStatus), - WaitFailed(ProcessError), - Cancelled, -} - -/// Internal report from a job task back to the actor loop. Kept off the -/// public [`KwMessage`] protocol: no caller can fake a completion. -enum JobEvent { - Finished(JobOutcome), -} - -/// Where RestorePreviousBranch switches back to: the branch HEAD was on -/// when the last job was accepted, and the tree that branch lives in. -/// Session-only, deliberately not persisted. -struct RestoreContext { - tree_path: String, - branch: String, -} - -/// What the actor remembers about the running job while the detached task -/// owns the process itself (see [`run_job`]). -struct JobState { - kind: KwJobKind, - phase: KwPhase, - kernel_tree_id: String, - branch: String, - /// Tree path, kw-env output dir, and build arch as probed at accept - /// time. The build runs under these, so the completion record - /// describes this snapshot — not whatever the tree's configuration - /// says by the time the job ends. - tree_path: String, - output_dir: Option, - arch: Option, - log_path: PathBuf, - /// Present for deploy kinds so a BuildThenDeploy job can spawn the - /// deploy process at the build/deploy boundary without the original - /// StartRequest. - deploy: Option, - /// `None` once a cancel has been requested; a second `Cancel` is an - /// idempotent ack. - cancel_tx: Option>, -} - -/// Deploy argv inputs snapshotted at accept. The remote and options are -/// resolved before the job starts so a chain cannot silently retarget -/// mid-build. -#[derive(Clone)] -struct JobDeploy { - remote: KwRemote, - options: DeployOptions, - extra_args: Vec, - /// Release of the kernel being deployed, from the build record, so - /// the deploy log can be checked for a GRUB menu that never listed - /// it. `None` until a BuildThenDeploy build has been recorded. - kernelrelease: Option, -} - pub struct KwActor { rx: mpsc::Receiver, job_event_rx: mpsc::Receiver, diff --git a/src/kw/history.rs b/src/kw/history.rs index 337b7dc..0f1eb90 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -5,57 +5,20 @@ //! refreshed from lore. use mockall::automock; -use serde::{Deserialize, Serialize}; use serde_json::from_reader; use std::{collections::HashMap, io, path::Path, sync::Arc}; use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait, JsonUtils}; +pub use crate::kw::models::history::KwApplyRecord; +#[cfg(unix)] +pub use crate::kw::models::history::KwBuildRecord; + pub const APPLY_HISTORY_FILENAME: &str = "kw_apply_history.json"; #[cfg(unix)] pub const BUILD_HISTORY_FILENAME: &str = "kw_build_history.json"; -/// One recorded `git am` application of a lore patchset to a kernel tree. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct KwApplyRecord { - pub message_id: String, - pub kernel_tree_id: String, - /// Snapshot of `KernelTree.path` when the record was written. - pub tree_path: String, - pub applied_branch: String, - pub base_branch: String, - /// RFC3339 timestamp. - pub applied_at: String, -} - -/// One recorded `kw build` attempt on a kernel tree branch, whether it -/// succeeded or not. Failed attempts are stored so deploy-alone can refuse -/// them instead of treating the tree as never built. -/// -/// `message_id`, `arch`, `image_path`, and `kernelrelease` are optional: a -/// build can target a branch no patchset was applied to, `arch` is unknown -/// when image discovery had to glob, and a failed build may never have -/// produced an image or a kernelrelease. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct KwBuildRecord { - pub kernel_tree_id: String, - /// Snapshot of `KernelTree.path` when the record was written. - pub tree_path: String, - pub message_id: Option, - pub branch: String, - pub arch: Option, - pub image_path: Option, - /// Resolved kw-env `O=` dir at build time, if an env was active. - pub output_dir: Option, - pub kernelrelease: Option, - pub log_path: String, - /// RFC3339 timestamp. Readers compare parsed timestamps; records with - /// unparseable `built_at` values sort oldest. - pub built_at: String, - pub success: bool, -} - /// message id → kernel tree id → record: applying the same patchset to /// several trees keeps one record per tree. type ApplyRecords = HashMap>; diff --git a/src/kw/mod.rs b/src/kw/mod.rs index c8faa7f..8d87ec3 100644 --- a/src/kw/mod.rs +++ b/src/kw/mod.rs @@ -10,6 +10,7 @@ pub mod history; #[cfg(unix)] pub mod log_scan; pub mod messages; +pub mod models; pub mod readiness; pub mod remote; pub mod status; diff --git a/src/kw/models/history.rs b/src/kw/models/history.rs new file mode 100644 index 0000000..25446d4 --- /dev/null +++ b/src/kw/models/history.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +/// One recorded `git am` application of a lore patchset to a kernel tree. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct KwApplyRecord { + pub message_id: String, + pub kernel_tree_id: String, + /// Snapshot of `KernelTree.path` when the record was written. + pub tree_path: String, + pub applied_branch: String, + pub base_branch: String, + /// RFC3339 timestamp. + pub applied_at: String, +} + +/// One recorded `kw build` attempt on a kernel tree branch, whether it +/// succeeded or not. Failed attempts are stored so deploy-alone can refuse +/// them instead of treating the tree as never built. +/// +/// `message_id`, `arch`, `image_path`, and `kernelrelease` are optional: a +/// build can target a branch no patchset was applied to, `arch` is unknown +/// when image discovery had to glob, and a failed build may never have +/// produced an image or a kernelrelease. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct KwBuildRecord { + pub kernel_tree_id: String, + /// Snapshot of `KernelTree.path` when the record was written. + pub tree_path: String, + pub message_id: Option, + pub branch: String, + pub arch: Option, + pub image_path: Option, + /// Resolved kw-env `O=` dir at build time, if an env was active. + pub output_dir: Option, + pub kernelrelease: Option, + pub log_path: String, + /// RFC3339 timestamp. Readers compare parsed timestamps; records with + /// unparseable `built_at` values sort oldest. + pub built_at: String, + pub success: bool, +} diff --git a/src/kw/models/job.rs b/src/kw/models/job.rs new file mode 100644 index 0000000..d9ca770 --- /dev/null +++ b/src/kw/models/job.rs @@ -0,0 +1,72 @@ +use std::{path::PathBuf, process::ExitStatus}; + +use tokio::sync::oneshot; + +use crate::{ + infrastructure::process::ProcessError, + kw::{ + messages::DeployOptions, + models::remote::KwRemote, + status::{KwJobKind, KwPhase}, + }, +}; + +/// How a job's process ended, as observed by the detached task that owns +/// the process handle. +pub(crate) enum JobOutcome { + Exited(ExitStatus), + WaitFailed(ProcessError), + Cancelled, +} + +/// Internal report from a job task back to the actor loop. Kept off the +/// public [`KwMessage`] protocol: no caller can fake a completion. +pub(crate) enum JobEvent { + Finished(JobOutcome), +} + +/// Where RestorePreviousBranch switches back to: the branch HEAD was on +/// when the last job was accepted, and the tree that branch lives in. +/// Session-only, deliberately not persisted. +pub(crate) struct RestoreContext { + pub(crate) tree_path: String, + pub(crate) branch: String, +} + +/// What the actor remembers about the running job while the detached task +/// owns the process itself (see [`run_job`]). +pub(crate) struct JobState { + pub(crate) kind: KwJobKind, + pub(crate) phase: KwPhase, + pub(crate) kernel_tree_id: String, + pub(crate) branch: String, + /// Tree path, kw-env output dir, and build arch as probed at accept + /// time. The build runs under these, so the completion record + /// describes this snapshot — not whatever the tree's configuration + /// says by the time the job ends. + pub(crate) tree_path: String, + pub(crate) output_dir: Option, + pub(crate) arch: Option, + pub(crate) log_path: PathBuf, + /// Present for deploy kinds so a BuildThenDeploy job can spawn the + /// deploy process at the build/deploy boundary without the original + /// StartRequest. + pub(crate) deploy: Option, + /// `None` once a cancel has been requested; a second `Cancel` is an + /// idempotent ack. + pub(crate) cancel_tx: Option>, +} + +/// Deploy argv inputs snapshotted at accept. The remote and options are +/// resolved before the job starts so a chain cannot silently retarget +/// mid-build. +#[derive(Clone)] +pub(crate) struct JobDeploy { + pub(crate) remote: KwRemote, + pub(crate) options: DeployOptions, + pub(crate) extra_args: Vec, + /// Release of the kernel being deployed, from the build record, so + /// the deploy log can be checked for a GRUB menu that never listed + /// it. `None` until a BuildThenDeploy build has been recorded. + pub(crate) kernelrelease: Option, +} diff --git a/src/kw/models/mod.rs b/src/kw/models/mod.rs new file mode 100644 index 0000000..f196d84 --- /dev/null +++ b/src/kw/models/mod.rs @@ -0,0 +1,5 @@ +pub mod history; +#[cfg(unix)] +pub(crate) mod job; +pub mod readiness; +pub mod remote; diff --git a/src/kw/models/readiness.rs b/src/kw/models/readiness.rs new file mode 100644 index 0000000..ccd0a6d --- /dev/null +++ b/src/kw/models/readiness.rs @@ -0,0 +1,163 @@ +use thiserror::Error; + +use std::path::PathBuf; + +use crate::{ + infrastructure::{env::EnvError, file_system::FileSystemError}, + kw::models::remote::{KwRemote, RemoteRefusal}, +}; + +/// Errors from readiness probes for states where "absent" is not a normal +/// situation (unlike a missing `.kw` dir, which is a readiness verdict). +#[derive(Debug, Error)] +pub enum KwReadinessError { + #[error("filesystem error: {0}")] + Fs(#[from] FileSystemError), + #[error("cannot resolve the kw cache dir: {0}")] + Env(#[from] EnvError), +} + +/// Readiness of a configured kernel tree for kw operations. +#[cfg_attr(not(unix), expect(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TreeReadiness { + /// Kernel root, kw-initialized, with a `.config`. `arch` is the literal + /// `arch=` value from `.kw/build.config`; `None` means the key is unset + /// and image discovery will glob `arch/*/boot/` instead — a deliberate + /// divergence from kw, whose own fallback is the merged kw-config + /// `arch` (see [`find_newest_kernel_image`]). + Ready { arch: Option }, + /// The configured path is not a directory. + Missing, + /// Directory exists but lacks the files and directories kw's + /// `is_kernel_root` expects at a kernel tree root. + NotAKernelRoot, + /// No `.kw/` directory: `kw init` was never run in this tree. + MissingKwDir, + /// No `.config` at the build root (the tree itself, or the kw env's + /// output dir when one is active). + MissingKernelConfig, + /// A kw env is active but the source tree still holds an in-tree + /// `.config` or `include/config/`: kbuild refuses an `O=` build of an + /// unclean source tree, so every build fails within seconds. + InTreeBuildArtifacts, +} + +/// Result of comparing the version kw reports against [`KW_MIN_VERSION`]. +/// +/// Advisory only: kw's shipped VERSION file is stale (it reads `beta-0.9` +/// even at the 0.10 tag), so `Below` can fire on a genuinely recent kw and +/// must never gate functionality — the raw line is carried verbatim so the +/// UI can show exactly what kw reported. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KwVersionCheck { + Meets, + Below(String), + /// The version output could not be obtained or parsed. + Unknown, +} + +/// Probe of the kw binary on `PATH`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KwBinaryProbe { + pub available: bool, + /// First line of `kw --version` output, verbatim. + pub version_line: Option, + pub check: KwVersionCheck, +} + +impl std::fmt::Display for TreeReadiness { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TreeReadiness::Ready { .. } => write!(f, "ready"), + TreeReadiness::Missing => write!(f, "the configured path is not a directory"), + TreeReadiness::NotAKernelRoot => write!( + f, + "the directory is not a kernel tree root (missing files like \ + Makefile or dirs like arch/)" + ), + TreeReadiness::MissingKwDir => { + write!(f, "kw init was never run in this tree (no .kw/ directory)") + } + TreeReadiness::MissingKernelConfig => { + write!( + f, + "no .config at the build root (the tree, or the kw env's O=)" + ) + } + TreeReadiness::InTreeBuildArtifacts => write!( + f, + "in-tree .config / include/config block kw env (O=) builds; run \ + `make mrproper` in the tree (the env's O= is untouched)" + ), + } + } +} + +/// Why a deploy-without-build was refused. Each variant's message is the +/// actionable explanation. +#[cfg_attr(not(unix), expect(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum DeployAloneRefusal { + #[error("the kernel tree is not ready: {0}")] + TreeNotReady(TreeReadiness), + #[error("no build recorded for this tree and branch; run a build first")] + NoBuildRecord, + #[error("the last build of this branch failed; rebuild before deploying")] + LastBuildFailed, + #[error( + "the last build was on branch '{recorded}', but the deploy target is '{current}'; \ + rebuild on the target branch before deploying" + )] + HeadMismatch { recorded: String, current: String }, + #[error( + "the kernel tree moved from '{recorded}' to '{current}' since the last build; \ + rebuild before deploying" + )] + TreePathDrift { recorded: String, current: String }, + #[error( + "the last build ran with a different kw env (O=) than the active one; \ + rebuild in the active env before deploying" + )] + OutputDirMismatch, + #[error( + "no kernel image (*Image) found under arch/*/boot; rebuild before deploying \ + (with no arch= in .kw/build.config, kw probes arch/x86_64/boot/, which \ + does not exist in kernel trees — set arch= explicitly)" + )] + ImageMissing, +} + +/// Whether `.kw/deploy.config` (then the user-level copy) sets +/// `boot_into_new_kernel_once=no`. +/// +/// kw only treats the literal value `no` as off (`src/deploy.sh`); any other +/// value, including a missing key, leaves the option on. [`Unknown`] is +/// therefore a confirm-to-proceed gate, same as [`On`]: patch-hub cannot +/// pass a CLI off-switch. +#[cfg_attr(not(unix), expect(dead_code))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BootOnceState { + Off, + On, + Unknown, +} + +/// Snapshot of tree, kw binary, and history probes used to decide whether +/// a job can start, and why not. +#[derive(Debug, Clone)] +pub struct KwReadiness { + pub kw_binary: KwBinaryProbe, + pub tree: TreeReadiness, + /// Active kw env's `O=` dir, if any. + pub output_dir: Option, + /// `Ok(())` is a self-sufficient verdict: tree readiness is already + /// conjoined in, so a caller cannot forget to check `tree` as well. + pub deploy_alone: Result<(), DeployAloneRefusal>, + /// Currently checked-out branch. `None` when HEAD is detached or + /// `git branch --show-current` could not be read. + pub current_branch: Option, + /// Resolved `--remote` target, or why none could be chosen. + pub deploy_remote: Result, + pub boot_once: BootOnceState, +} diff --git a/src/kw/models/remote.rs b/src/kw/models/remote.rs new file mode 100644 index 0000000..5c62123 --- /dev/null +++ b/src/kw/models/remote.rs @@ -0,0 +1,41 @@ +use thiserror::Error; + +/// A Host stanza from `remote.config` that has a usable Hostname. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KwRemote { + pub name: String, + pub hostname: String, + pub port: u16, + pub user: Option, +} + +/// Why a deploy remote could not be resolved. Each variant's message is +/// the actionable explanation. +#[cfg_attr(not(unix), expect(dead_code))] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RemoteRefusal { + #[error( + "no remotes configured; configure a remote with `kw remote --set-default` \ + or edit `.kw/remote.config`" + )] + NoRemotesConfigured, + #[error( + "{count} remotes configured with no default; set one with \ + `kw remote --set-default` or edit `.kw/remote.config`" + )] + NoDefault { count: usize }, + #[error( + "default remote '{name}' is not a Host in remote.config; set a valid \ + default with `kw remote --set-default`" + )] + DefaultNotFound { name: String }, +} + +/// Parsed contents of a `remote.config` file. Incomplete Host stanzas +/// (no Hostname, or an unparseable Port) are dropped rather than guessed. +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ParsedRemoteConfig { + pub default: Option, + pub hosts: Vec, +} diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index e60d112..241bbd2 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -13,67 +13,32 @@ #[cfg(unix)] use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use thiserror::Error; #[cfg(unix)] use std::collections::HashMap; #[cfg(unix)] -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[cfg(unix)] use std::time::SystemTime; #[cfg(unix)] -use crate::infrastructure::file_system::FileSystemTrait; +use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait}; use crate::infrastructure::{ - env::{EnvError, EnvTrait}, - file_system::FileSystemError, + env::EnvTrait, shell::{ShellCommand, ShellTrait}, }; #[cfg(unix)] use crate::kw::remote; -use crate::kw::remote::{KwRemote, RemoteRefusal}; #[cfg(unix)] use crate::{ config::KernelTree, kw::history::{KwBuildRecord, KwHistoryStore}, }; -/// Errors from readiness probes for states where "absent" is not a normal -/// situation (unlike a missing `.kw` dir, which is a readiness verdict). -#[derive(Debug, Error)] -pub enum KwReadinessError { - #[error("filesystem error: {0}")] - Fs(#[from] FileSystemError), - #[error("cannot resolve the kw cache dir: {0}")] - Env(#[from] EnvError), -} - -/// Readiness of a configured kernel tree for kw operations. -#[cfg_attr(not(unix), expect(dead_code))] -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TreeReadiness { - /// Kernel root, kw-initialized, with a `.config`. `arch` is the literal - /// `arch=` value from `.kw/build.config`; `None` means the key is unset - /// and image discovery will glob `arch/*/boot/` instead — a deliberate - /// divergence from kw, whose own fallback is the merged kw-config - /// `arch` (see [`find_newest_kernel_image`]). - Ready { arch: Option }, - /// The configured path is not a directory. - Missing, - /// Directory exists but lacks the files and directories kw's - /// `is_kernel_root` expects at a kernel tree root. - NotAKernelRoot, - /// No `.kw/` directory: `kw init` was never run in this tree. - MissingKwDir, - /// No `.config` at the build root (the tree itself, or the kw env's - /// output dir when one is active). - MissingKernelConfig, - /// A kw env is active but the source tree still holds an in-tree - /// `.config` or `include/config/`: kbuild refuses an `O=` build of an - /// unclean source tree, so every build fails within seconds. - InTreeBuildArtifacts, -} +pub use crate::kw::models::readiness::{ + BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwReadinessError, + KwVersionCheck, TreeReadiness, +}; /// Parses kw's `key=value` config format (`.kw/build.config`, /// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: @@ -335,29 +300,6 @@ pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option /// Minimum kw version this integration is verified against. pub const KW_MIN_VERSION: (u32, u32) = (0, 10); -/// Result of comparing the version kw reports against [`KW_MIN_VERSION`]. -/// -/// Advisory only: kw's shipped VERSION file is stale (it reads `beta-0.9` -/// even at the 0.10 tag), so `Below` can fire on a genuinely recent kw and -/// must never gate functionality — the raw line is carried verbatim so the -/// UI can show exactly what kw reported. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum KwVersionCheck { - Meets, - Below(String), - /// The version output could not be obtained or parsed. - Unknown, -} - -/// Probe of the kw binary on `PATH`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct KwBinaryProbe { - pub available: bool, - /// First line of `kw --version` output, verbatim. - pub version_line: Option, - pub check: KwVersionCheck, -} - /// Probes for the kw binary (`which kw`) and, when present, its version /// (`kw --version`, whose first line is the version string; repo-mode and /// installed kw both print `Branch:`/`Commit:` lines after it). @@ -409,83 +351,6 @@ fn parse_kw_version(line: &str) -> Option<(u32, u32)> { Some((major, minor.parse().ok()?)) } -impl std::fmt::Display for TreeReadiness { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TreeReadiness::Ready { .. } => write!(f, "ready"), - TreeReadiness::Missing => write!(f, "the configured path is not a directory"), - TreeReadiness::NotAKernelRoot => write!( - f, - "the directory is not a kernel tree root (missing files like \ - Makefile or dirs like arch/)" - ), - TreeReadiness::MissingKwDir => { - write!(f, "kw init was never run in this tree (no .kw/ directory)") - } - TreeReadiness::MissingKernelConfig => { - write!( - f, - "no .config at the build root (the tree, or the kw env's O=)" - ) - } - TreeReadiness::InTreeBuildArtifacts => write!( - f, - "in-tree .config / include/config block kw env (O=) builds; run \ - `make mrproper` in the tree (the env's O= is untouched)" - ), - } - } -} - -/// Why a deploy-without-build was refused. Each variant's message is the -/// actionable explanation. -#[cfg_attr(not(unix), expect(dead_code))] -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum DeployAloneRefusal { - #[error("the kernel tree is not ready: {0}")] - TreeNotReady(TreeReadiness), - #[error("no build recorded for this tree and branch; run a build first")] - NoBuildRecord, - #[error("the last build of this branch failed; rebuild before deploying")] - LastBuildFailed, - #[error( - "the last build was on branch '{recorded}', but the deploy target is '{current}'; \ - rebuild on the target branch before deploying" - )] - HeadMismatch { recorded: String, current: String }, - #[error( - "the kernel tree moved from '{recorded}' to '{current}' since the last build; \ - rebuild before deploying" - )] - TreePathDrift { recorded: String, current: String }, - #[error( - "the last build ran with a different kw env (O=) than the active one; \ - rebuild in the active env before deploying" - )] - OutputDirMismatch, - #[error( - "no kernel image (*Image) found under arch/*/boot; rebuild before deploying \ - (with no arch= in .kw/build.config, kw probes arch/x86_64/boot/, which \ - does not exist in kernel trees — set arch= explicitly)" - )] - ImageMissing, -} - -/// Whether `.kw/deploy.config` (then the user-level copy) sets -/// `boot_into_new_kernel_once=no`. -/// -/// kw only treats the literal value `no` as off (`src/deploy.sh`); any other -/// value, including a missing key, leaves the option on. [`Unknown`] is -/// therefore a confirm-to-proceed gate, same as [`On`]: patch-hub cannot -/// pass a CLI off-switch. -#[cfg_attr(not(unix), expect(dead_code))] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BootOnceState { - Off, - On, - Unknown, -} - /// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess @@ -605,25 +470,6 @@ pub fn check_deploy_alone( Ok(()) } -/// Snapshot of tree, kw binary, and history probes used to decide whether -/// a job can start, and why not. -#[derive(Debug, Clone)] -pub struct KwReadiness { - pub kw_binary: KwBinaryProbe, - pub tree: TreeReadiness, - /// Active kw env's `O=` dir, if any. - pub output_dir: Option, - /// `Ok(())` is a self-sufficient verdict: tree readiness is already - /// conjoined in, so a caller cannot forget to check `tree` as well. - pub deploy_alone: Result<(), DeployAloneRefusal>, - /// Currently checked-out branch. `None` when HEAD is detached or - /// `git branch --show-current` could not be read. - pub current_branch: Option, - /// Resolved `--remote` target, or why none could be chosen. - pub deploy_remote: Result, - pub boot_once: BootOnceState, -} - /// Runs all readiness probes for `tree` and composes them into a /// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — /// resolving it (via git) is the caller's job, keeping these probes pure. @@ -711,6 +557,7 @@ mod tests { shell::{MockShellTrait, ShellOutput}, }; use crate::kw::history::FileKwHistoryStore; + use crate::kw::remote::RemoteRefusal; use super::*; diff --git a/src/kw/remote.rs b/src/kw/remote.rs index f58793d..c5af231 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -15,19 +15,12 @@ #[cfg(unix)] use std::path::{Path, PathBuf}; -use thiserror::Error; - #[cfg(unix)] use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; -/// A Host stanza from `remote.config` that has a usable Hostname. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct KwRemote { - pub name: String, - pub hostname: String, - pub port: u16, - pub user: Option, -} +#[cfg(unix)] +pub use crate::kw::models::remote::ParsedRemoteConfig; +pub use crate::kw::models::remote::{KwRemote, RemoteRefusal}; impl KwRemote { /// The `--remote` token kw's deploy parser accepts: @@ -40,37 +33,6 @@ impl KwRemote { } } -/// Why a deploy remote could not be resolved. Each variant's message is -/// the actionable explanation. -#[cfg_attr(not(unix), expect(dead_code))] -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum RemoteRefusal { - #[error( - "no remotes configured; configure a remote with `kw remote --set-default` \ - or edit `.kw/remote.config`" - )] - NoRemotesConfigured, - #[error( - "{count} remotes configured with no default; set one with \ - `kw remote --set-default` or edit `.kw/remote.config`" - )] - NoDefault { count: usize }, - #[error( - "default remote '{name}' is not a Host in remote.config; set a valid \ - default with `kw remote --set-default`" - )] - DefaultNotFound { name: String }, -} - -/// Parsed contents of a `remote.config` file. Incomplete Host stanzas -/// (no Hostname, or an unparseable Port) are dropped rather than guessed. -#[cfg(unix)] -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct ParsedRemoteConfig { - pub default: Option, - pub hosts: Vec, -} - /// Parses kw's ssh-config-like `remote.config`. Blank lines and comments /// are skipped; `#kw-default=` (anywhere) names the default Host, /// last occurrence winning; `Host ` starts a stanza; `Hostname`, From 0597ffb34012668d3fa8643d817e4199a8493d4b Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 18:41:32 -0300 Subject: [PATCH 15/61] refactor(app): move view-model types into app/models Signed-off-by: lorenzoberts --- src/app/mod.rs | 1 + src/app/models/mod.rs | 1 + src/app/models/view_model.rs | 195 ++++++++++++++++++++++++++++++++++ src/app/view_model.rs | 200 ++--------------------------------- 4 files changed, 203 insertions(+), 194 deletions(-) create mode 100644 src/app/models/mod.rs create mode 100644 src/app/models/view_model.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 4e178c9..1f667ad 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod flows; pub mod handle; pub mod input; pub(crate) mod loading; +pub mod models; pub mod popup; pub mod screens; pub mod state; diff --git a/src/app/models/mod.rs b/src/app/models/mod.rs new file mode 100644 index 0000000..8b9fd60 --- /dev/null +++ b/src/app/models/mod.rs @@ -0,0 +1 @@ +pub mod view_model; diff --git a/src/app/models/view_model.rs b/src/app/models/view_model.rs new file mode 100644 index 0000000..535d37f --- /dev/null +++ b/src/app/models/view_model.rs @@ -0,0 +1,195 @@ +use ratatui::text::Text; + +/// One mailing list entry shown in the selection list. +#[derive(Clone, Debug)] +pub struct MailingListEntry { + pub name: String, + pub description: String, +} + +/// Match state of the user's mailing-list filter string. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TargetListStatus { + /// Nothing typed yet. + Empty, + /// The typed string exactly names an existing list. + ExactMatch, + /// The typed string is a prefix of at least one existing list. + PrefixMatch, + /// The typed string matches no list. + NoMatch, +} + +/// A single row in a paginated patchset list (Latest or Bookmarked). +#[derive(Clone, Debug)] +pub struct PatchSummaryRow { + pub title: String, + pub author_name: String, + pub version: usize, + pub total_in_series: usize, + /// Absolute index across pages. + pub absolute_index: usize, +} + +/// Pre-computed counts of code-review trailers for the currently previewed patch. +#[derive(Clone, Debug)] +pub struct TagTrailerCounts { + pub reviewed_by: usize, + pub tested_by: usize, + pub acked_by: usize, +} + +/// One row in the edit-config form. +#[derive(Clone, Debug)] +pub struct ConfigEntryRow { + pub label: String, + pub value: String, + pub is_highlighted: bool, + /// Whether this row is currently being typed into. + pub is_editing: bool, + /// In-progress text while editing; empty when not editing this row. + pub edit_cursor_value: String, +} + +#[derive(Clone, Debug)] +pub struct MailingListSelectionViewModel { + pub entries: Vec, + pub highlighted_index: usize, + pub target_list: String, + pub target_list_status: TargetListStatus, +} + +#[derive(Clone, Debug)] +pub struct BookmarkedViewModel { + pub rows: Vec, + pub selected_index: usize, +} + +#[derive(Clone, Debug)] +pub struct LatestPatchsetsViewModel { + pub rows: Vec, + pub selected_index: usize, + pub page_number: usize, + pub target_list: String, +} + +#[derive(Clone, Debug)] +pub struct PatchsetDetailsViewModel { + pub patch_title: String, + pub author_name: String, + pub version: usize, + pub patch_count: usize, + pub last_updated: String, + pub tag_trailer_counts: TagTrailerCounts, + /// Pre-computed `"(0, 2, …)"` string when patches are staged for reply. + /// `None` when nothing is staged. + pub staged_to_reply: Option, + /// ANSI-rendered diff/cover text for each patch entry. + pub preview_entries: Vec>, + pub preview_index: usize, + pub preview_scroll_offset: usize, + pub preview_pan: usize, + pub preview_fullscreen: bool, + /// Pre-computed title for the preview pane, including the `[REVIEWED-BY]` + /// or `[REVIEWED-BY]*` suffix when applicable. + pub preview_title: String, + pub is_bookmarked: bool, + pub is_apply_staged: bool, + /// Whether the patch at `preview_index` is staged for a Reviewed-by reply. + pub is_current_patch_reply_staged: bool, +} + +#[derive(Clone, Debug)] +pub struct EditConfigViewModel { + pub entries: Vec, + pub is_editing_mode: bool, + pub editing_tree_selector: bool, +} + +/// KwOps dashboard. Labels only; actions stay in AppState. +#[derive(Clone, Debug)] +pub struct KwOpsViewModel { + pub patchset_title: String, + pub message_id: String, + pub kernel_tree_id: String, + pub tree_path: String, + pub branch: String, + pub extra_args: String, + pub branch_focused: bool, + pub extras_focused: bool, + pub editing: bool, + pub kw_binary: String, + pub tree_readiness: String, + pub output_dir: String, + pub job_status: String, + pub command: String, + pub start_label: String, + pub cancel_label: String, + pub restore_label: String, + pub remote: String, + pub boot_once: String, + pub deploy_command: String, + pub deploy_label: String, + pub build_deploy_label: String, + pub branch_guidance: Option, + /// Deploy warnings of the last job, joined with ` | `. + pub warnings: Option, + /// First error line of the last failed job's log. + pub first_error: Option, + /// Full log of the current or last job; the pane only shows its tail. + pub log_path: Option, + pub log_tail: String, +} + +#[derive(Clone, Debug)] +pub enum PopupViewBody { + Text(String), + Keybinds { + description: Option, + formatted_keybinds: String, + }, + ReviewTrailers { + reviewed_by: String, + tested_by: String, + acked_by: String, + }, + /// Choice labels only; the selected action stays in `AppPopup`. + Confirm { + body: String, + options: Vec, + selected: usize, + }, +} + +#[derive(Clone, Debug)] +pub struct PopupViewModel { + pub title: String, + pub body: PopupViewBody, + pub scroll_offset: (u16, u16), + /// `(width_percent, height_percent)` of the terminal area. + pub dimensions: (u16, u16), +} + +#[derive(Clone, Debug)] +pub enum ScreenViewModel { + MailingListSelection(MailingListSelectionViewModel), + Bookmarked(BookmarkedViewModel), + Latest(LatestPatchsetsViewModel), + PatchsetDetails(PatchsetDetailsViewModel), + EditConfig(EditConfigViewModel), + // Boxed to keep the enum small (clippy::large_enum_variant): KwOps + // carries one owned string per dashboard row. + KwOps(Box), +} + +/// Owned, typed projection of [`AppState`] for one TUI frame. +/// +/// Built by [`project_state`]; consumed by `UiCore::build_scene`. +#[derive(Clone, Debug)] +pub struct AppViewModel { + pub screen: ScreenViewModel, + pub popup: Option, + /// Compact running-job copy for the nav bar. `None` when idle or + /// after a terminal outcome — those belong on KwOps, not globally. + pub kw_running: Option, +} diff --git a/src/app/view_model.rs b/src/app/view_model.rs index de5d229..a685b54 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -8,7 +8,6 @@ //! `UiScene` nodes. use ansi_to_tui::IntoText; -use ratatui::text::Text; use super::{ popup::AppPopup, @@ -22,199 +21,12 @@ use crate::kw::{ status::{deploy_exit_hint, KwJobStatus, KwPhase, KwStatusSnapshot}, }; -/// One mailing list entry shown in the selection list. -#[derive(Clone, Debug)] -pub struct MailingListEntry { - pub name: String, - pub description: String, -} - -/// Match state of the user's mailing-list filter string. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TargetListStatus { - /// Nothing typed yet. - Empty, - /// The typed string exactly names an existing list. - ExactMatch, - /// The typed string is a prefix of at least one existing list. - PrefixMatch, - /// The typed string matches no list. - NoMatch, -} - -/// A single row in a paginated patchset list (Latest or Bookmarked). -#[derive(Clone, Debug)] -pub struct PatchSummaryRow { - pub title: String, - pub author_name: String, - pub version: usize, - pub total_in_series: usize, - /// Absolute index across pages. - pub absolute_index: usize, -} - -/// Pre-computed counts of code-review trailers for the currently previewed patch. -#[derive(Clone, Debug)] -pub struct TagTrailerCounts { - pub reviewed_by: usize, - pub tested_by: usize, - pub acked_by: usize, -} - -/// One row in the edit-config form. -#[derive(Clone, Debug)] -pub struct ConfigEntryRow { - pub label: String, - pub value: String, - pub is_highlighted: bool, - /// Whether this row is currently being typed into. - pub is_editing: bool, - /// In-progress text while editing; empty when not editing this row. - pub edit_cursor_value: String, -} - -#[derive(Clone, Debug)] -pub struct MailingListSelectionViewModel { - pub entries: Vec, - pub highlighted_index: usize, - pub target_list: String, - pub target_list_status: TargetListStatus, -} - -#[derive(Clone, Debug)] -pub struct BookmarkedViewModel { - pub rows: Vec, - pub selected_index: usize, -} - -#[derive(Clone, Debug)] -pub struct LatestPatchsetsViewModel { - pub rows: Vec, - pub selected_index: usize, - pub page_number: usize, - pub target_list: String, -} - -#[derive(Clone, Debug)] -pub struct PatchsetDetailsViewModel { - pub patch_title: String, - pub author_name: String, - pub version: usize, - pub patch_count: usize, - pub last_updated: String, - pub tag_trailer_counts: TagTrailerCounts, - /// Pre-computed `"(0, 2, …)"` string when patches are staged for reply. - /// `None` when nothing is staged. - pub staged_to_reply: Option, - /// ANSI-rendered diff/cover text for each patch entry. - pub preview_entries: Vec>, - pub preview_index: usize, - pub preview_scroll_offset: usize, - pub preview_pan: usize, - pub preview_fullscreen: bool, - /// Pre-computed title for the preview pane, including the `[REVIEWED-BY]` - /// or `[REVIEWED-BY]*` suffix when applicable. - pub preview_title: String, - pub is_bookmarked: bool, - pub is_apply_staged: bool, - /// Whether the patch at `preview_index` is staged for a Reviewed-by reply. - pub is_current_patch_reply_staged: bool, -} - -#[derive(Clone, Debug)] -pub struct EditConfigViewModel { - pub entries: Vec, - pub is_editing_mode: bool, - pub editing_tree_selector: bool, -} - -/// KwOps dashboard. Labels only; actions stay in AppState. -#[derive(Clone, Debug)] -pub struct KwOpsViewModel { - pub patchset_title: String, - pub message_id: String, - pub kernel_tree_id: String, - pub tree_path: String, - pub branch: String, - pub extra_args: String, - pub branch_focused: bool, - pub extras_focused: bool, - pub editing: bool, - pub kw_binary: String, - pub tree_readiness: String, - pub output_dir: String, - pub job_status: String, - pub command: String, - pub start_label: String, - pub cancel_label: String, - pub restore_label: String, - pub remote: String, - pub boot_once: String, - pub deploy_command: String, - pub deploy_label: String, - pub build_deploy_label: String, - pub branch_guidance: Option, - /// Deploy warnings of the last job, joined with ` | `. - pub warnings: Option, - /// First error line of the last failed job's log. - pub first_error: Option, - /// Full log of the current or last job; the pane only shows its tail. - pub log_path: Option, - pub log_tail: String, -} - -#[derive(Clone, Debug)] -pub enum PopupViewBody { - Text(String), - Keybinds { - description: Option, - formatted_keybinds: String, - }, - ReviewTrailers { - reviewed_by: String, - tested_by: String, - acked_by: String, - }, - /// Choice labels only; the selected action stays in `AppPopup`. - Confirm { - body: String, - options: Vec, - selected: usize, - }, -} - -#[derive(Clone, Debug)] -pub struct PopupViewModel { - pub title: String, - pub body: PopupViewBody, - pub scroll_offset: (u16, u16), - /// `(width_percent, height_percent)` of the terminal area. - pub dimensions: (u16, u16), -} - -#[derive(Clone, Debug)] -pub enum ScreenViewModel { - MailingListSelection(MailingListSelectionViewModel), - Bookmarked(BookmarkedViewModel), - Latest(LatestPatchsetsViewModel), - PatchsetDetails(PatchsetDetailsViewModel), - EditConfig(EditConfigViewModel), - // Boxed to keep the enum small (clippy::large_enum_variant): KwOps - // carries one owned string per dashboard row. - KwOps(Box), -} - -/// Owned, typed projection of [`AppState`] for one TUI frame. -/// -/// Built by [`project_state`]; consumed by `UiCore::build_scene`. -#[derive(Clone, Debug)] -pub struct AppViewModel { - pub screen: ScreenViewModel, - pub popup: Option, - /// Compact running-job copy for the nav bar. `None` when idle or - /// after a terminal outcome — those belong on KwOps, not globally. - pub kw_running: Option, -} +pub use crate::app::models::view_model::{ + AppViewModel, BookmarkedViewModel, ConfigEntryRow, EditConfigViewModel, KwOpsViewModel, + LatestPatchsetsViewModel, MailingListEntry, MailingListSelectionViewModel, PatchSummaryRow, + PatchsetDetailsViewModel, PopupViewBody, PopupViewModel, ScreenViewModel, TagTrailerCounts, + TargetListStatus, +}; /// Projects `state` into an owned [`AppViewModel`]. /// From 148f149125b4af3004fae8582388d632a1fba09d Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 18:42:10 -0300 Subject: [PATCH 16/61] refactor(app): move popup and KwOps state types into app/models Signed-off-by: lorenzoberts --- src/app/models/kw_ops.rs | 47 ++++++++++++++++++++++++++++++++ src/app/models/mod.rs | 2 ++ src/app/models/popup.rs | 56 ++++++++++++++++++++++++++++++++++++++ src/app/popup.rs | 57 +-------------------------------------- src/app/screens/kw_ops.rs | 46 +------------------------------ 5 files changed, 107 insertions(+), 101 deletions(-) create mode 100644 src/app/models/kw_ops.rs create mode 100644 src/app/models/popup.rs diff --git a/src/app/models/kw_ops.rs b/src/app/models/kw_ops.rs new file mode 100644 index 0000000..4faddd8 --- /dev/null +++ b/src/app/models/kw_ops.rs @@ -0,0 +1,47 @@ +use crate::{config::KernelTree, kw::readiness::KwReadiness}; + +/// Which editable KwOps field is focused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KwOpsFocus { + #[default] + Branch, + ExtraArgs, +} + +/// Which deploy start was interrupted by the boot-once confirm popup. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeployStartKind { + Deploy, + BuildThenDeploy, +} + +/// Form state on the KwOps screen. Job status lives in [`crate::app::state::KwUiState::status`]. +#[derive(Clone, Debug)] +pub struct KwOpsState { + pub patchset_title: String, + pub message_id: String, + pub kernel_tree_id: String, + pub tree: KernelTree, + pub branch: String, + pub extra_args: String, + pub focus: KwOpsFocus, + pub editing: bool, + pub edit_buffer: String, + pub readiness: KwReadiness, + /// True when readiness could not name HEAD; Start stays disabled until + /// the user types a branch (we never guess from `KernelTree.branch`). + pub head_unreadable: bool, + /// Bounded tail of the job log, refreshed by AppActor while KwOps is + /// visible and a job is running. + pub log_tail: String, + pub cancel_requested: bool, + /// Optimistic lock so a second Start before the watch snapshot + /// arrives is ignored instead of refused with an error popup. + pub start_requested: bool, + /// True after the user confirmed boot-into-new-kernel-once for this + /// KwOps visit. Preserved across `reenter` so a later Start does not + /// re-prompt in the same session. + pub boot_once_acknowledged: bool, + /// Deploy start waiting on the boot-once confirm popup. + pub pending_deploy: Option, +} diff --git a/src/app/models/mod.rs b/src/app/models/mod.rs index 8b9fd60..38b9928 100644 --- a/src/app/models/mod.rs +++ b/src/app/models/mod.rs @@ -1 +1,3 @@ +pub mod kw_ops; +pub mod popup; pub mod view_model; diff --git a/src/app/models/popup.rs b/src/app/models/popup.rs new file mode 100644 index 0000000..8f57917 --- /dev/null +++ b/src/app/models/popup.rs @@ -0,0 +1,56 @@ +/// Action taken when the user confirms a choice popup. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmAction { + CancelKwAndQuit, + Wait, + /// User accepted kw's one-shot boot into the new kernel. + ProceedWithBootOnce, + /// User declined the boot-once gate; the pending deploy is dropped. + BackOut, +} + +/// Concrete, cloneable popup state stored in `AppState`. +#[derive(Clone, Debug)] +pub enum AppPopup { + Info { + title: String, + body: String, + scroll: (u16, u16), + max_scroll: (u16, u16), + dimensions: (u16, u16), + }, + Help { + title: Option, + description: Option, + formatted_keybinds: String, + scroll: (u16, u16), + max_scroll: (u16, u16), + dimensions: (u16, u16), + }, + ReviewTrailers { + reviewed_by: String, + tested_by: String, + acked_by: String, + scroll: (u16, u16), + max_scroll: (u16, u16), + dimensions: (u16, u16), + }, + Confirm { + title: String, + body: String, + options: Vec<(String, ConfirmAction)>, + selected: usize, + dimensions: (u16, u16), + }, +} + +/// Fluent builder for `AppPopup::Help`. +/// +/// Mirrors the API of the old `HelpPopUpBuilder` so handler call sites change +/// minimally. +#[derive(Default)] +pub struct AppHelpBuilder { + pub(crate) title: Option, + pub(crate) description: Option, + pub(crate) keybinds: Vec<(String, String)>, +} diff --git a/src/app/popup.rs b/src/app/popup.rs index 1eb7c3f..4c92462 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -11,51 +11,7 @@ use crate::{ lore::domain::patch::Author, }; -/// Action taken when the user confirms a choice popup. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ConfirmAction { - CancelKwAndQuit, - Wait, - /// User accepted kw's one-shot boot into the new kernel. - ProceedWithBootOnce, - /// User declined the boot-once gate; the pending deploy is dropped. - BackOut, -} - -/// Concrete, cloneable popup state stored in `AppState`. -#[derive(Clone, Debug)] -pub enum AppPopup { - Info { - title: String, - body: String, - scroll: (u16, u16), - max_scroll: (u16, u16), - dimensions: (u16, u16), - }, - Help { - title: Option, - description: Option, - formatted_keybinds: String, - scroll: (u16, u16), - max_scroll: (u16, u16), - dimensions: (u16, u16), - }, - ReviewTrailers { - reviewed_by: String, - tested_by: String, - acked_by: String, - scroll: (u16, u16), - max_scroll: (u16, u16), - dimensions: (u16, u16), - }, - Confirm { - title: String, - body: String, - options: Vec<(String, ConfirmAction)>, - selected: usize, - dimensions: (u16, u16), - }, -} +pub use crate::app::models::popup::{AppHelpBuilder, AppPopup, ConfirmAction}; impl AppPopup { /// Create an informational text popup. @@ -237,17 +193,6 @@ There is no CLI off-switch; this popup only lets you proceed or back out." } } -/// Fluent builder for `AppPopup::Help`. -/// -/// Mirrors the API of the old `HelpPopUpBuilder` so handler call sites change -/// minimally. -#[derive(Default)] -pub struct AppHelpBuilder { - title: Option, - description: Option, - keybinds: Vec<(String, String)>, -} - impl AppHelpBuilder { pub fn title(mut self, t: &str) -> Self { self.title = Some(t.to_string()); diff --git a/src/app/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index 6d772ff..16bf682 100644 --- a/src/app/screens/kw_ops.rs +++ b/src/app/screens/kw_ops.rs @@ -1,50 +1,6 @@ use crate::{config::KernelTree, kw::readiness::KwReadiness}; -/// Which editable KwOps field is focused. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum KwOpsFocus { - #[default] - Branch, - ExtraArgs, -} - -/// Which deploy start was interrupted by the boot-once confirm popup. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DeployStartKind { - Deploy, - BuildThenDeploy, -} - -/// Form state on the KwOps screen. Job status lives in [`crate::app::state::KwUiState::status`]. -#[derive(Clone, Debug)] -pub struct KwOpsState { - pub patchset_title: String, - pub message_id: String, - pub kernel_tree_id: String, - pub tree: KernelTree, - pub branch: String, - pub extra_args: String, - pub focus: KwOpsFocus, - pub editing: bool, - pub edit_buffer: String, - pub readiness: KwReadiness, - /// True when readiness could not name HEAD; Start stays disabled until - /// the user types a branch (we never guess from `KernelTree.branch`). - pub head_unreadable: bool, - /// Bounded tail of the job log, refreshed by AppActor while KwOps is - /// visible and a job is running. - pub log_tail: String, - pub cancel_requested: bool, - /// Optimistic lock so a second Start before the watch snapshot - /// arrives is ignored instead of refused with an error popup. - pub start_requested: bool, - /// True after the user confirmed boot-into-new-kernel-once for this - /// KwOps visit. Preserved across `reenter` so a later Start does not - /// re-prompt in the same session. - pub boot_once_acknowledged: bool, - /// Deploy start waiting on the boot-once confirm popup. - pub pending_deploy: Option, -} +pub use crate::app::models::kw_ops::{DeployStartKind, KwOpsFocus, KwOpsState}; impl KwOpsState { pub fn new( From fe9eb3375cfd3c77f830ab6adfb9d8000cbe9fcb Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 18:42:10 -0300 Subject: [PATCH 17/61] refactor(lore): move cache types into lore/application/models Signed-off-by: lorenzoberts --- src/lore/application/cache.rs | 92 ++-------------------------- src/lore/application/mod.rs | 1 + src/lore/application/models/cache.rs | 90 +++++++++++++++++++++++++++ src/lore/application/models/mod.rs | 1 + 4 files changed, 97 insertions(+), 87 deletions(-) create mode 100644 src/lore/application/models/cache.rs create mode 100644 src/lore/application/models/mod.rs diff --git a/src/lore/application/cache.rs b/src/lore/application/cache.rs index b42078f..9ea248d 100644 --- a/src/lore/application/cache.rs +++ b/src/lore/application/cache.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, time::{Duration, SystemTime}, }; @@ -8,56 +8,13 @@ use crate::lore::{ domain::{mailing_list::MailingList, patch::Patch, patchset::PatchFeedIndex}, }; -// ── Cache mode ──────────────────────────────────────────────────────────────── - -/// Caller intent for a cache-backed `LoreService` operation. -/// -/// * `UseCache` — return in-memory data if present and not stale; fall back to -/// disk; return an error/empty if neither is available. Never hits the network -/// unless the data is genuinely missing. -/// * `Refresh` — discard any cached data and unconditionally fetch from the -/// network, then persist the result. -/// * `Bypass` — fetch from the network and return the result without reading -/// or writing the in-memory cache. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CacheMode { - UseCache, - Refresh, - #[expect(dead_code)] - Bypass, -} - -// ── TTL configuration ───────────────────────────────────────────────────────── - -/// Per-data-type TTL configuration injected into `LoreService`. -/// -/// Defaults are chosen conservatively: -/// * mailing lists change rarely → 24 h -/// * feed pages are refreshed by the user explicitly → 1 h -/// * patchsets are immutable historical artefacts → never expire -pub struct CacheTtl { - pub mailing_lists: Duration, - pub feed: Duration, - pub patchset: Duration, -} - -impl Default for CacheTtl { - fn default() -> Self { - CacheTtl { - mailing_lists: Duration::from_secs(86_400), - feed: Duration::from_secs(3_600), - patchset: Duration::MAX, - } - } -} +pub use crate::lore::application::models::cache::{ + BootstrapLoreData, CacheMode, CacheTtl, FeedCacheEntry, LoreCache, MailingListsCacheEntry, + PatchsetCacheEntry, PatchsetCacheKey, +}; // ── Mailing lists cache ─────────────────────────────────────────────────────── -pub struct MailingListsCacheEntry { - pub lists: Vec, - pub fetched_at: SystemTime, -} - impl MailingListsCacheEntry { pub fn new(lists: Vec) -> Self { MailingListsCacheEntry { @@ -73,15 +30,6 @@ impl MailingListsCacheEntry { // ── Feed cache ──────────────────────────────────────────────────────────────── -/// A feed cache entry wraps the domain-level `PatchFeedIndex` with TTL metadata. -/// -/// `PatchFeedIndex` (in `domain/patchset.rs`) remains a pure domain type; -/// all cache concerns live here. -pub struct FeedCacheEntry { - pub index: PatchFeedIndex, - pub fetched_at: SystemTime, -} - impl FeedCacheEntry { pub fn new(index: PatchFeedIndex) -> Self { FeedCacheEntry { @@ -97,12 +45,6 @@ impl FeedCacheEntry { // ── Patchset cache ──────────────────────────────────────────────────────────── -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct PatchsetCacheKey { - pub message_id: String, - pub version: usize, -} - impl PatchsetCacheKey { pub fn from_patch(patch: &Patch) -> Self { PatchsetCacheKey { @@ -112,13 +54,6 @@ impl PatchsetCacheKey { } } -pub struct PatchsetCacheEntry { - pub patchset_path: String, - pub raw_patches: Vec, - pub tag_summary: Vec, - pub fetched_at: SystemTime, -} - impl PatchsetCacheEntry { pub fn new( patchset_path: String, @@ -140,12 +75,6 @@ impl PatchsetCacheEntry { // ── Aggregate cache ─────────────────────────────────────────────────────────── -pub struct LoreCache { - pub lists: Option, - pub feeds: HashMap, - pub patchsets: HashMap, -} - impl LoreCache { pub fn new() -> Self { LoreCache { @@ -161,14 +90,3 @@ impl Default for LoreCache { Self::new() } } - -// ── Bootstrap result ────────────────────────────────────────────────────────── - -/// Data returned by `LoreService::warm_bootstrap_cache`, used to initialise -/// `App` without it knowing about persistence paths or network policy. -#[derive(Default)] -pub struct BootstrapLoreData { - pub mailing_lists: Vec, - pub bookmarks: Vec, - pub reviewed: HashMap>, -} diff --git a/src/lore/application/mod.rs b/src/lore/application/mod.rs index 4b0a1ac..e2d4c54 100644 --- a/src/lore/application/mod.rs +++ b/src/lore/application/mod.rs @@ -4,4 +4,5 @@ pub mod dto; pub mod errors; pub mod handle; pub mod messages; +pub mod models; pub mod service; diff --git a/src/lore/application/models/cache.rs b/src/lore/application/models/cache.rs new file mode 100644 index 0000000..37a45b6 --- /dev/null +++ b/src/lore/application/models/cache.rs @@ -0,0 +1,90 @@ +use std::{ + collections::{HashMap, HashSet}, + time::{Duration, SystemTime}, +}; + +use crate::lore::{ + application::dto::PatchTagSummary, + domain::{mailing_list::MailingList, patch::Patch, patchset::PatchFeedIndex}, +}; + +/// Caller intent for a cache-backed `LoreService` operation. +/// +/// * `UseCache` — return in-memory data if present and not stale; fall back to +/// disk; return an error/empty if neither is available. Never hits the network +/// unless the data is genuinely missing. +/// * `Refresh` — discard any cached data and unconditionally fetch from the +/// network, then persist the result. +/// * `Bypass` — fetch from the network and return the result without reading +/// or writing the in-memory cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheMode { + UseCache, + Refresh, + #[expect(dead_code)] + Bypass, +} + +/// Per-data-type TTL configuration injected into `LoreService`. +/// +/// Defaults are chosen conservatively: +/// * mailing lists change rarely → 24 h +/// * feed pages are refreshed by the user explicitly → 1 h +/// * patchsets are immutable historical artefacts → never expire +pub struct CacheTtl { + pub mailing_lists: Duration, + pub feed: Duration, + pub patchset: Duration, +} + +impl Default for CacheTtl { + fn default() -> Self { + CacheTtl { + mailing_lists: Duration::from_secs(86_400), + feed: Duration::from_secs(3_600), + patchset: Duration::MAX, + } + } +} + +pub struct MailingListsCacheEntry { + pub lists: Vec, + pub fetched_at: SystemTime, +} + +/// A feed cache entry wraps the domain-level `PatchFeedIndex` with TTL metadata. +/// +/// `PatchFeedIndex` (in `domain/patchset.rs`) remains a pure domain type; +/// all cache concerns live here. +pub struct FeedCacheEntry { + pub index: PatchFeedIndex, + pub fetched_at: SystemTime, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct PatchsetCacheKey { + pub message_id: String, + pub version: usize, +} + +pub struct PatchsetCacheEntry { + pub patchset_path: String, + pub raw_patches: Vec, + pub tag_summary: Vec, + pub fetched_at: SystemTime, +} + +pub struct LoreCache { + pub lists: Option, + pub feeds: HashMap, + pub patchsets: HashMap, +} + +/// Data returned by `LoreService::warm_bootstrap_cache`, used to initialise +/// `App` without it knowing about persistence paths or network policy. +#[derive(Default)] +pub struct BootstrapLoreData { + pub mailing_lists: Vec, + pub bookmarks: Vec, + pub reviewed: HashMap>, +} diff --git a/src/lore/application/models/mod.rs b/src/lore/application/models/mod.rs new file mode 100644 index 0000000..a5c08fd --- /dev/null +++ b/src/lore/application/models/mod.rs @@ -0,0 +1 @@ +pub mod cache; From 34891a429824132e82c77d31af092222180ab45c Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 18:59:27 -0300 Subject: [PATCH 18/61] refactor(kw): group readiness probes into ReadinessService The readiness probes stay associated functions on a unit struct, with the same arguments and the same per-item unix gates. Private helpers whose names did not start with a verb are renamed to find_newest_image_in, read_image_mtime, read_boot_once_from_file, and resolve_xdg_kw_config_file. No tests removed. Signed-off-by: lorenzoberts --- src/app/dependencies.rs | 4 +- src/kw/actor/mod.rs | 32 +- src/kw/models/readiness.rs | 2 +- src/kw/readiness.rs | 1154 +++++++++++++++++++----------------- 4 files changed, 639 insertions(+), 553 deletions(-) diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 7402e4c..8db6caf 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -4,7 +4,7 @@ use crate::{ app::errors::AppError, config::ConfigSnapshot, infrastructure::{env::EnvTrait, shell::ShellTrait}, - kw::readiness::{probe_kw_binary, KwVersionCheck}, + kw::readiness::{KwVersionCheck, ReadinessService}, render_prefs::PatchRenderer, }; @@ -62,7 +62,7 @@ pub(crate) fn check_external_deps( _ => {} } - let kw = probe_kw_binary(env, shell); + let kw = ReadinessService::probe_kw_binary(env, shell); if !kw.available { event!( Level::WARN, diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 51bc29c..e0be2b3 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -260,13 +260,17 @@ impl KwActor { let shell = Arc::clone(&self.shell); let tree_path_for_probe = tree_path.clone(); let (kw_binary, output_dir, tree_readiness) = tokio::task::spawn_blocking(move || { - let kw_binary = readiness::probe_kw_binary(&*env, &*shell); + let kw_binary = readiness::ReadinessService::probe_kw_binary(&*env, &*shell); if !kw_binary.available { return Err(KwStartError::KwBinaryMissing); } - let output_dir = readiness::resolve_output_dir(&*fs, &*env, &tree_path_for_probe)?; - let tree_readiness = - readiness::probe_tree(&*fs, &tree_path_for_probe, output_dir.as_deref()); + let output_dir = + readiness::ReadinessService::resolve_output_dir(&*fs, &*env, &tree_path_for_probe)?; + let tree_readiness = readiness::ReadinessService::probe_tree( + &*fs, + &tree_path_for_probe, + output_dir.as_deref(), + ); Ok((kw_binary, output_dir, tree_readiness)) }) .await @@ -742,8 +746,12 @@ impl KwActor { // produced. let (image_path, kernelrelease) = if success { ( - readiness::find_newest_kernel_image(&*self.fs, build_root, job.arch.as_deref()), - readiness::read_kernelrelease(&*self.fs, build_root), + readiness::ReadinessService::find_newest_kernel_image( + &*self.fs, + build_root, + job.arch.as_deref(), + ), + readiness::ReadinessService::read_kernelrelease(&*self.fs, build_root), ) } else { (None, None) @@ -836,7 +844,7 @@ impl KwActor { let tree = tree.clone(); tokio::task::spawn_blocking(move || { let head = head_branch(&*shell, tree.path()); - readiness::evaluate_readiness( + readiness::ReadinessService::evaluate_readiness( &*fs, &*env, &*shell, @@ -918,8 +926,12 @@ fn prepare_deploy_blocking( let mut kernelrelease = None; if kind == KwJobKind::Deploy { let (record, latest) = history.build_records(&request.kernel_tree_id, &request.branch)?; - let image = readiness::find_newest_kernel_image(fs, output_dir.unwrap_or(tree_path), arch); - readiness::check_deploy_alone( + let image = readiness::ReadinessService::find_newest_kernel_image( + fs, + output_dir.unwrap_or(tree_path), + arch, + ); + readiness::ReadinessService::check_deploy_alone( record.as_ref(), latest.as_ref(), &request.tree, @@ -930,7 +942,7 @@ fn prepare_deploy_blocking( .map_err(KwStartError::DeployAloneRefused)?; kernelrelease = record.and_then(|record| record.kernelrelease); } - let boot_once = readiness::probe_boot_once(fs, env, tree_path); + let boot_once = readiness::ReadinessService::probe_boot_once(fs, env, tree_path); if matches!(boot_once, BootOnceState::On | BootOnceState::Unknown) && !options.boot_once_acknowledged { diff --git a/src/kw/models/readiness.rs b/src/kw/models/readiness.rs index ccd0a6d..b600fb6 100644 --- a/src/kw/models/readiness.rs +++ b/src/kw/models/readiness.rs @@ -25,7 +25,7 @@ pub enum TreeReadiness { /// `arch=` value from `.kw/build.config`; `None` means the key is unset /// and image discovery will glob `arch/*/boot/` instead — a deliberate /// divergence from kw, whose own fallback is the merged kw-config - /// `arch` (see [`find_newest_kernel_image`]). + /// `arch` (see [`crate::kw::readiness::ReadinessService::find_newest_kernel_image`]). Ready { arch: Option }, /// The configured path is not a directory. Missing, diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 241bbd2..83de225 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -7,7 +7,7 @@ //! do, instead of being a parallel interpretation that can silently drift //! from it. The deliberate divergences — the `arch`-unset glob fallback //! and the non-recursive boot-dir scan — are documented on -//! [`find_newest_kernel_image`]. The probes are pure functions over +//! [`ReadinessService::find_newest_kernel_image`]. The probes are pure functions over //! injected infrastructure traits; KwActor composes them into the //! `GetReadiness` snapshot. @@ -40,506 +40,515 @@ pub use crate::kw::models::readiness::{ KwVersionCheck, TreeReadiness, }; -/// Parses kw's `key=value` config format (`.kw/build.config`, -/// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: -/// blank lines and lines starting with `#` are skipped, everything from the -/// last `#` on is stripped as a trailing comment, the key has all -/// whitespace removed, and the value is trimmed. Lines without `=` are -/// ignored, and a final line without a trailing newline still counts. -#[cfg(unix)] -pub fn parse_kw_config(content: &str) -> HashMap { - let mut entries = HashMap::new(); - for line in content.lines() { - if line.starts_with('#') || line.is_empty() { - continue; - } - let uncommented = match line.rfind('#') { - Some(index) => &line[..index], - None => line, - }; - let Some((key, value)) = uncommented.split_once('=') else { - continue; - }; - let key: String = key.chars().filter(|c| !c.is_whitespace()).collect(); - entries.insert(key, value.trim().to_string()); - } - entries -} - -/// Mirrors kw's `is_kernel_root`: the same files and directories kw checks -/// (also the set `get_maintainer.pl` relies on). `MAINTAINERS` is checked -/// with `exists` because kw uses `-e` on it and `-f` on the other files. -#[cfg(unix)] -pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { - const FILES: [&str; 5] = ["COPYING", "CREDITS", "Kbuild", "Makefile", "README"]; - const DIRS: [&str; 10] = [ - "Documentation", - "arch", - "include", - "drivers", - "fs", - "init", - "ipc", - "kernel", - "lib", - "scripts", - ]; +pub struct ReadinessService; - FILES.iter().all(|file| fs.is_file(&path.join(file))) - && fs.exists(&path.join("MAINTAINERS")) - && DIRS.iter().all(|dir| fs.is_dir(&path.join(dir))) -} +/// Minimum kw version this integration is verified against. +pub const KW_MIN_VERSION: (u32, u32) = (0, 10); -/// Probes whether `tree_path` is a kernel tree ready for kw operations. -/// `output_dir` is the resolved kw-env `O=` path when an env is active: kw -/// refuses to activate an env while an in-tree `.config` exists -/// (`kw_env.sh::validate_env_before_switch`), so with an env active the -/// `.config` lives only at the env's `O=` dir. -#[cfg(unix)] -pub fn probe_tree( - fs: &dyn FileSystemTrait, - tree_path: &Path, - output_dir: Option<&Path>, -) -> TreeReadiness { - if !fs.is_dir(tree_path) { - return TreeReadiness::Missing; - } - if !is_kernel_root(fs, tree_path) { - return TreeReadiness::NotAKernelRoot; - } - if !fs.is_dir(&tree_path.join(".kw")) { - return TreeReadiness::MissingKwDir; - } - if output_dir.is_some() - && (fs.is_file(&tree_path.join(".config")) - || fs.is_dir(&tree_path.join("include").join("config"))) - { - return TreeReadiness::InTreeBuildArtifacts; - } - let build_root = output_dir.unwrap_or(tree_path); - if !fs.is_file(&build_root.join(".config")) { - return TreeReadiness::MissingKernelConfig; - } - TreeReadiness::Ready { - arch: read_build_arch(fs, tree_path), +impl ReadinessService { + /// Parses kw's `key=value` config format (`.kw/build.config`, + /// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: + /// blank lines and lines starting with `#` are skipped, everything from the + /// last `#` on is stripped as a trailing comment, the key has all + /// whitespace removed, and the value is trimmed. Lines without `=` are + /// ignored, and a final line without a trailing newline still counts. + #[cfg(unix)] + pub fn parse_kw_config(content: &str) -> HashMap { + let mut entries = HashMap::new(); + for line in content.lines() { + if line.starts_with('#') || line.is_empty() { + continue; + } + let uncommented = match line.rfind('#') { + Some(index) => &line[..index], + None => line, + }; + let Some((key, value)) = uncommented.split_once('=') else { + continue; + }; + let key: String = key.chars().filter(|c| !c.is_whitespace()).collect(); + entries.insert(key, value.trim().to_string()); + } + entries + } + + /// Mirrors kw's `is_kernel_root`: the same files and directories kw checks + /// (also the set `get_maintainer.pl` relies on). `MAINTAINERS` is checked + /// with `exists` because kw uses `-e` on it and `-f` on the other files. + #[cfg(unix)] + pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { + const FILES: [&str; 5] = ["COPYING", "CREDITS", "Kbuild", "Makefile", "README"]; + const DIRS: [&str; 10] = [ + "Documentation", + "arch", + "include", + "drivers", + "fs", + "init", + "ipc", + "kernel", + "lib", + "scripts", + ]; + + FILES.iter().all(|file| fs.is_file(&path.join(file))) + && fs.exists(&path.join("MAINTAINERS")) + && DIRS.iter().all(|dir| fs.is_dir(&path.join(dir))) + } + + /// Probes whether `tree_path` is a kernel tree ready for kw operations. + /// `output_dir` is the resolved kw-env `O=` path when an env is active: kw + /// refuses to activate an env while an in-tree `.config` exists + /// (`kw_env.sh::validate_env_before_switch`), so with an env active the + /// `.config` lives only at the env's `O=` dir. + #[cfg(unix)] + pub fn probe_tree( + fs: &dyn FileSystemTrait, + tree_path: &Path, + output_dir: Option<&Path>, + ) -> TreeReadiness { + if !fs.is_dir(tree_path) { + return TreeReadiness::Missing; + } + if !Self::is_kernel_root(fs, tree_path) { + return TreeReadiness::NotAKernelRoot; + } + if !fs.is_dir(&tree_path.join(".kw")) { + return TreeReadiness::MissingKwDir; + } + if output_dir.is_some() + && (fs.is_file(&tree_path.join(".config")) + || fs.is_dir(&tree_path.join("include").join("config"))) + { + return TreeReadiness::InTreeBuildArtifacts; + } + let build_root = output_dir.unwrap_or(tree_path); + if !fs.is_file(&build_root.join(".config")) { + return TreeReadiness::MissingKernelConfig; + } + TreeReadiness::Ready { + arch: Self::read_build_arch(fs, tree_path), + } } -} -/// Reads the literal `arch=` value from `/.kw/build.config`, the same -/// value kw's image discovery globs under `arch//boot/`. Returns -/// `None` when the file or the key is absent or empty — kw's -/// `${build_config[arch]:-...}` expansion treats empty as unset — meaning -/// the caller falls back to globbing `arch/*/boot/`, a deliberate -/// divergence from kw's merged-config fallback (see -/// [`find_newest_kernel_image`]). -#[cfg(unix)] -pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { - let content = fs - .read_to_string(&tree_path.join(".kw").join("build.config")) - .ok()?; - let arch = parse_kw_config(&content).remove("arch")?; - if arch.is_empty() { - None - } else { - Some(arch) + /// Reads the literal `arch=` value from `/.kw/build.config`, the same + /// value kw's image discovery globs under `arch//boot/`. Returns + /// `None` when the file or the key is absent or empty — kw's + /// `${build_config[arch]:-...}` expansion treats empty as unset — meaning + /// the caller falls back to globbing `arch/*/boot/`, a deliberate + /// divergence from kw's merged-config fallback (see + /// [`ReadinessService::find_newest_kernel_image`]). + #[cfg(unix)] + pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { + let content = fs + .read_to_string(&tree_path.join(".kw").join("build.config")) + .ok()?; + let arch = Self::parse_kw_config(&content).remove("arch")?; + if arch.is_empty() { + None + } else { + Some(arch) + } } -} -/// Resolves kw's active build output dir (`O=`) for `tree_path`, mirroring -/// kw's env handling: the env name is the content of -/// `/.kw/env.current` (trailing newlines stripped, like bash's -/// `$(< ...)`), and the output dir is -/// `{XDG_CACHE_HOME | ~/.cache}/kw/envs//`. -/// The tree path is encoded like kw 0.10's `get_encoded_pwd` — standard -/// base64 with padding, no wrapping — after trimming trailing slashes, -/// since kw encodes `$PWD` after changing into the tree. The encoded path -/// may contain `/` (standard alphabet), producing nested directories; kw -/// has the same behavior. -/// -/// kw's launcher recomputes the cache dir unconditionally, so a -/// user-exported `KW_CACHE_DIR` is intentionally ignored here too. The -/// `KWORKFLOW` rename knob is not honored: it exists for kw development. -/// -/// Returns `Ok(None)` when no env is active. The resolved dir is not -/// required to exist: kw/make create it on first build. An unreadable -/// `env.current` or an unresolvable cache base (neither `XDG_CACHE_HOME` -/// nor `HOME` set) is an error, since the env state is then unknown — -/// kw's "active but unresolvable" case. -#[cfg(unix)] -pub fn resolve_output_dir( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, -) -> Result, KwReadinessError> { - let env_file = tree_path.join(".kw").join("env.current"); - if !fs.is_file(&env_file) { - return Ok(None); - } - let env_name = fs - .read_to_string(&env_file)? - .trim_end_matches('\n') - .to_string(); - // An empty env.current names no env; kw's $(<) read yields the same - // empty string and kw then behaves as if no env were active. - if env_name.is_empty() { - return Ok(None); - } - - let cache_base = match env.var("XDG_CACHE_HOME") { - // bash's `:-` (and the XDG spec) treat a set-but-empty value as - // unset; env::var would happily return it as Ok(""). - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.cache", env.var("HOME")?), - }; - let trimmed = tree_path.to_string_lossy(); - let normalized = match trimmed.trim_end_matches('/') { - "" => "/", - path => path, - }; - let encoded = BASE64.encode(normalized); - Ok(Some( - Path::new(&cache_base) - .join("kw") - .join("envs") - .join(encoded) - .join(env_name), - )) -} + /// Resolves kw's active build output dir (`O=`) for `tree_path`, mirroring + /// kw's env handling: the env name is the content of + /// `/.kw/env.current` (trailing newlines stripped, like bash's + /// `$(< ...)`), and the output dir is + /// `{XDG_CACHE_HOME | ~/.cache}/kw/envs//`. + /// The tree path is encoded like kw 0.10's `get_encoded_pwd` — standard + /// base64 with padding, no wrapping — after trimming trailing slashes, + /// since kw encodes `$PWD` after changing into the tree. The encoded path + /// may contain `/` (standard alphabet), producing nested directories; kw + /// has the same behavior. + /// + /// kw's launcher recomputes the cache dir unconditionally, so a + /// user-exported `KW_CACHE_DIR` is intentionally ignored here too. The + /// `KWORKFLOW` rename knob is not honored: it exists for kw development. + /// + /// Returns `Ok(None)` when no env is active. The resolved dir is not + /// required to exist: kw/make create it on first build. An unreadable + /// `env.current` or an unresolvable cache base (neither `XDG_CACHE_HOME` + /// nor `HOME` set) is an error, since the env state is then unknown — + /// kw's "active but unresolvable" case. + #[cfg(unix)] + pub fn resolve_output_dir( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> Result, KwReadinessError> { + let env_file = tree_path.join(".kw").join("env.current"); + if !fs.is_file(&env_file) { + return Ok(None); + } + let env_name = fs + .read_to_string(&env_file)? + .trim_end_matches('\n') + .to_string(); + // An empty env.current names no env; kw's $(<) read yields the same + // empty string and kw then behaves as if no env were active. + if env_name.is_empty() { + return Ok(None); + } -/// Finds the newest kernel image under `/arch/`. Candidate -/// basenames must end with `Image` (the `-name '*Image'` in kw's -/// `get_kernel_binary_name` is case-sensitive, so `Image.gz` and `image` -/// are excluded) and the most recently modified one wins, with ties broken -/// by descending path (kw's `sort -r | head -1`). -/// -/// With `arch`, only `arch//boot/` is probed — exactly kw's behavior. -/// Without `arch` this is a **deliberate divergence**, not a mirror: kw -/// falls back to the merged kw-config `arch` (packaged default `x86_64`, a -/// directory that does not exist in kernel trees, so `kw deploy` then fails -/// with exit 125), and patch-hub does not read kw's global config layers. -/// Globbing every `arch/*/boot/` gives a more useful readiness signal than -/// probing a directory that is never there — at the cost of possibly -/// reporting an image kw would not find. A green image probe with `arch=` -/// unset is therefore not a guarantee kw deploy will locate one; setting -/// `arch=` in `.kw/build.config` makes the two agree. -/// -/// Second deliberate deviation: kw's `find` recurses into boot/ -/// subdirectories, while this scans only the top level. Kernel images for -/// every arch kw supports are produced directly in boot/ (subdirs like -/// compressed/ or dts/ never hold `*Image` files), and find does not -/// descend into symlinked dirs either, so the behaviors agree on real -/// trees. -#[cfg(unix)] -pub fn find_newest_kernel_image( - fs: &dyn FileSystemTrait, - build_root: &Path, - arch: Option<&str>, -) -> Option { - match arch { - Some(arch) => newest_image_in(fs, &build_root.join("arch").join(arch).join("boot")), - None => fs - .read_dir(&build_root.join("arch")) + let cache_base = match env.var("XDG_CACHE_HOME") { + // bash's `:-` (and the XDG spec) treat a set-but-empty value as + // unset; env::var would happily return it as Ok(""). + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.cache", env.var("HOME")?), + }; + let trimmed = tree_path.to_string_lossy(); + let normalized = match trimmed.trim_end_matches('/') { + "" => "/", + path => path, + }; + let encoded = BASE64.encode(normalized); + Ok(Some( + Path::new(&cache_base) + .join("kw") + .join("envs") + .join(encoded) + .join(env_name), + )) + } + + /// Finds the newest kernel image under `/arch/`. Candidate + /// basenames must end with `Image` (the `-name '*Image'` in kw's + /// `get_kernel_binary_name` is case-sensitive, so `Image.gz` and `image` + /// are excluded) and the most recently modified one wins, with ties broken + /// by descending path (kw's `sort -r | head -1`). + /// + /// With `arch`, only `arch//boot/` is probed — exactly kw's behavior. + /// Without `arch` this is a **deliberate divergence**, not a mirror: kw + /// falls back to the merged kw-config `arch` (packaged default `x86_64`, a + /// directory that does not exist in kernel trees, so `kw deploy` then fails + /// with exit 125), and patch-hub does not read kw's global config layers. + /// Globbing every `arch/*/boot/` gives a more useful readiness signal than + /// probing a directory that is never there — at the cost of possibly + /// reporting an image kw would not find. A green image probe with `arch=` + /// unset is therefore not a guarantee kw deploy will locate one; setting + /// `arch=` in `.kw/build.config` makes the two agree. + /// + /// Second deliberate deviation: kw's `find` recurses into boot/ + /// subdirectories, while this scans only the top level. Kernel images for + /// every arch kw supports are produced directly in boot/ (subdirs like + /// compressed/ or dts/ never hold `*Image` files), and find does not + /// descend into symlinked dirs either, so the behaviors agree on real + /// trees. + #[cfg(unix)] + pub fn find_newest_kernel_image( + fs: &dyn FileSystemTrait, + build_root: &Path, + arch: Option<&str>, + ) -> Option { + match arch { + Some(arch) => { + Self::find_newest_image_in(fs, &build_root.join("arch").join(arch).join("boot")) + } + None => fs + .read_dir(&build_root.join("arch")) + .ok()? + .into_iter() + .filter(|entry| fs.is_dir(entry)) + .filter_map(|arch_dir| { + Self::find_newest_image_in(fs, &arch_dir.join("boot")) + .map(|image| (Self::read_image_mtime(fs, &image), image)) + }) + .max_by_key(|(mtime, _)| *mtime) + .map(|(_, image)| image), + } + } + + /// Newest `*Image` file directly inside `boot_dir`, if any. + #[cfg(unix)] + fn find_newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { + fs.read_dir(boot_dir) .ok()? .into_iter() - .filter(|entry| fs.is_dir(entry)) - .filter_map(|arch_dir| { - newest_image_in(fs, &arch_dir.join("boot")) - .map(|image| (image_mtime(fs, &image), image)) + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with("Image")) + && fs.is_file(entry) }) + .map(|entry| (Self::read_image_mtime(fs, &entry), entry)) .max_by_key(|(mtime, _)| *mtime) - .map(|(_, image)| image), - } -} - -/// Newest `*Image` file directly inside `boot_dir`, if any. -#[cfg(unix)] -fn newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { - fs.read_dir(boot_dir) - .ok()? - .into_iter() - .filter(|entry| { - entry - .file_name() - .is_some_and(|name| name.to_string_lossy().ends_with("Image")) - && fs.is_file(entry) - }) - .map(|entry| (image_mtime(fs, &entry), entry)) - .max_by_key(|(mtime, _)| *mtime) - .map(|(_, entry)| entry) -} - -#[cfg(unix)] -fn image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { - fs.metadata(path) - .and_then(|meta| meta.modified().map_err(FileSystemError::from)) - .unwrap_or(SystemTime::UNIX_EPOCH) -} - -/// Reads the built kernel's release string from -/// `/include/config/kernel.release`, the file a kernel build -/// generates — cheaper than re-running `make kernelrelease`, and `None` -/// when the build never produced one (or produced an empty one). -#[cfg(unix)] -pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option { - let release = fs - .read_to_string( - &build_root - .join("include") - .join("config") - .join("kernel.release"), - ) - .ok()?; - let release = release.trim(); - if release.is_empty() { - None - } else { - Some(release.to_string()) + .map(|(_, entry)| entry) + } + + #[cfg(unix)] + fn read_image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { + fs.metadata(path) + .and_then(|meta| meta.modified().map_err(FileSystemError::from)) + .unwrap_or(SystemTime::UNIX_EPOCH) + } + + /// Reads the built kernel's release string from + /// `/include/config/kernel.release`, the file a kernel build + /// generates — cheaper than re-running `make kernelrelease`, and `None` + /// when the build never produced one (or produced an empty one). + #[cfg(unix)] + pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option { + let release = fs + .read_to_string( + &build_root + .join("include") + .join("config") + .join("kernel.release"), + ) + .ok()?; + let release = release.trim(); + if release.is_empty() { + None + } else { + Some(release.to_string()) + } } -} - -/// Minimum kw version this integration is verified against. -pub const KW_MIN_VERSION: (u32, u32) = (0, 10); -/// Probes for the kw binary (`which kw`) and, when present, its version -/// (`kw --version`, whose first line is the version string; repo-mode and -/// installed kw both print `Branch:`/`Commit:` lines after it). -pub fn probe_kw_binary(env: &dyn EnvTrait, shell: &dyn ShellTrait) -> KwBinaryProbe { - if !env.which("kw") { - return KwBinaryProbe { - available: false, - version_line: None, - check: KwVersionCheck::Unknown, + /// Probes for the kw binary (`which kw`) and, when present, its version + /// (`kw --version`, whose first line is the version string; repo-mode and + /// installed kw both print `Branch:`/`Commit:` lines after it). + pub fn probe_kw_binary(env: &dyn EnvTrait, shell: &dyn ShellTrait) -> KwBinaryProbe { + if !env.which("kw") { + return KwBinaryProbe { + available: false, + version_line: None, + check: KwVersionCheck::Unknown, + }; + } + let version_line = shell + .execute(&ShellCommand::new("kw").arg("--version")) + .ok() + .filter(|out| out.success) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .and_then(|stdout| stdout.lines().next().map(str::to_string)) + .filter(|line| !line.is_empty()); + let check = match &version_line { + Some(line) => Self::check_kw_version(line), + None => KwVersionCheck::Unknown, }; + KwBinaryProbe { + available: true, + version_line, + check, + } } - let version_line = shell - .execute(&ShellCommand::new("kw").arg("--version")) - .ok() - .filter(|out| out.success) - .and_then(|out| String::from_utf8(out.stdout).ok()) - .and_then(|stdout| stdout.lines().next().map(str::to_string)) - .filter(|line| !line.is_empty()); - let check = match &version_line { - Some(line) => check_kw_version(line), - None => KwVersionCheck::Unknown, - }; - KwBinaryProbe { - available: true, - version_line, - check, - } -} -fn check_kw_version(version_line: &str) -> KwVersionCheck { - match parse_kw_version(version_line) { - Some(version) if version >= KW_MIN_VERSION => KwVersionCheck::Meets, - Some(_) => KwVersionCheck::Below(version_line.to_string()), - None => KwVersionCheck::Unknown, + fn check_kw_version(version_line: &str) -> KwVersionCheck { + match Self::parse_kw_version(version_line) { + Some(version) if version >= KW_MIN_VERSION => KwVersionCheck::Meets, + Some(_) => KwVersionCheck::Below(version_line.to_string()), + None => KwVersionCheck::Unknown, + } } -} - -/// Extracts the first `X.Y[.Z]` pair from a version line: `0.10.0` and the -/// stale `beta-0.9` kw currently ships both parse. -fn parse_kw_version(line: &str) -> Option<(u32, u32)> { - let start = line.find(|c: char| c.is_ascii_digit())?; - let mut parts = line[start..].splitn(3, '.'); - let major = parts.next()?.parse().ok()?; - let minor: String = parts - .next()? - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); - Some((major, minor.parse().ok()?)) -} -/// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then -/// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but -/// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess -/// at the home copy. A readable tree file that simply omits the key still -/// falls through, matching kw's merged-config lookup. -#[cfg(unix)] -pub fn probe_boot_once( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, -) -> BootOnceState { - let local = tree_path.join(".kw").join("deploy.config"); - if fs.is_file(&local) { - match boot_once_from_file(fs, &local) { - Err(()) => return BootOnceState::Unknown, - Ok(Some(state)) => return state, - Ok(None) => {} + /// Extracts the first `X.Y[.Z]` pair from a version line: `0.10.0` and the + /// stale `beta-0.9` kw currently ships both parse. + fn parse_kw_version(line: &str) -> Option<(u32, u32)> { + let start = line.find(|c: char| c.is_ascii_digit())?; + let mut parts = line[start..].splitn(3, '.'); + let major = parts.next()?.parse().ok()?; + let minor: String = parts + .next()? + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + Some((major, minor.parse().ok()?)) + } + + /// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but + /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess + /// at the home copy. A readable tree file that simply omits the key still + /// falls through, matching kw's merged-config lookup. + #[cfg(unix)] + pub fn probe_boot_once( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> BootOnceState { + let local = tree_path.join(".kw").join("deploy.config"); + if fs.is_file(&local) { + match Self::read_boot_once_from_file(fs, &local) { + Err(()) => return BootOnceState::Unknown, + Ok(Some(state)) => return state, + Ok(None) => {} + } + } + let Some(global) = Self::resolve_xdg_kw_config_file(env, "deploy.config") else { + return BootOnceState::Unknown; + }; + if !fs.is_file(&global) { + return BootOnceState::Unknown; + } + match Self::read_boot_once_from_file(fs, &global) { + Ok(Some(state)) => state, + Ok(None) | Err(()) => BootOnceState::Unknown, } } - let Some(global) = xdg_kw_config_file(env, "deploy.config") else { - return BootOnceState::Unknown; - }; - if !fs.is_file(&global) { - return BootOnceState::Unknown; - } - match boot_once_from_file(fs, &global) { - Ok(Some(state)) => state, - Ok(None) | Err(()) => BootOnceState::Unknown, - } -} -#[cfg(unix)] -fn boot_once_from_file(fs: &dyn FileSystemTrait, path: &Path) -> Result, ()> { - let content = fs.read_to_string(path).map_err(|_| ())?; - Ok(parse_kw_config(&content) - .remove("boot_into_new_kernel_once") - .map(|value| match value.as_str() { - "no" => BootOnceState::Off, - "yes" => BootOnceState::On, - _ => BootOnceState::Unknown, - })) -} - -/// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty -/// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG -/// spec. -#[cfg(unix)] -fn xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), - }; - Some(Path::new(&config_home).join("kw").join(filename)) -} - -/// Deploy-alone readiness gate: a deploy without a preceding build is only -/// allowed when a successful build record exists for the tree and the -/// lookup branch, written against the same tree path and kw env, and a -/// kernel image is still discoverable. -/// -/// `record` is the lookup keyed by the deploy target branch. `latest` is -/// the newest record for the tree across branches. When the target has -/// no keyed record but another branch does, that is -/// [`DeployAloneRefusal::HeadMismatch`], not "no build recorded". -/// -/// This is only the record-matching half of the gate — it says nothing -/// about the tree's *current* state. [`evaluate_readiness`] conjoins -/// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over -/// calling this directly. -#[cfg(unix)] -pub fn check_deploy_alone( - record: Option<&KwBuildRecord>, - latest: Option<&KwBuildRecord>, - tree: &KernelTree, - head_branch: &str, - output_dir: Option<&Path>, - image: Option<&Path>, -) -> Result<(), DeployAloneRefusal> { - let record = match record { - Some(record) => record, - None => { - if let Some(latest) = latest { - if latest.branch != head_branch { - return Err(DeployAloneRefusal::HeadMismatch { - recorded: latest.branch.clone(), - current: head_branch.to_string(), - }); + #[cfg(unix)] + fn read_boot_once_from_file( + fs: &dyn FileSystemTrait, + path: &Path, + ) -> Result, ()> { + let content = fs.read_to_string(path).map_err(|_| ())?; + Ok(Self::parse_kw_config(&content) + .remove("boot_into_new_kernel_once") + .map(|value| match value.as_str() { + "no" => BootOnceState::Off, + "yes" => BootOnceState::On, + _ => BootOnceState::Unknown, + })) + } + + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty + /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG + /// spec. + #[cfg(unix)] + fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { + let config_home = match env.var("XDG_CONFIG_HOME") { + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.config", env.var("HOME").ok()?), + }; + Some(Path::new(&config_home).join("kw").join(filename)) + } + + /// Deploy-alone readiness gate: a deploy without a preceding build is only + /// allowed when a successful build record exists for the tree and the + /// lookup branch, written against the same tree path and kw env, and a + /// kernel image is still discoverable. + /// + /// `record` is the lookup keyed by the deploy target branch. `latest` is + /// the newest record for the tree across branches. When the target has + /// no keyed record but another branch does, that is + /// [`DeployAloneRefusal::HeadMismatch`], not "no build recorded". + /// + /// This is only the record-matching half of the gate — it says nothing + /// about the tree's *current* state. [`ReadinessService::evaluate_readiness`] conjoins + /// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over + /// calling this directly. + #[cfg(unix)] + pub fn check_deploy_alone( + record: Option<&KwBuildRecord>, + latest: Option<&KwBuildRecord>, + tree: &KernelTree, + head_branch: &str, + output_dir: Option<&Path>, + image: Option<&Path>, + ) -> Result<(), DeployAloneRefusal> { + let record = match record { + Some(record) => record, + None => { + if let Some(latest) = latest { + if latest.branch != head_branch { + return Err(DeployAloneRefusal::HeadMismatch { + recorded: latest.branch.clone(), + current: head_branch.to_string(), + }); + } } + return Err(DeployAloneRefusal::NoBuildRecord); } - return Err(DeployAloneRefusal::NoBuildRecord); + }; + if !record.success { + return Err(DeployAloneRefusal::LastBuildFailed); } - }; - if !record.success { - return Err(DeployAloneRefusal::LastBuildFailed); - } - if record.branch != head_branch { - return Err(DeployAloneRefusal::HeadMismatch { - recorded: record.branch.clone(), - current: head_branch.to_string(), - }); - } - // Trailing slashes are normalized away: a config edit that only adds - // or drops one does not move the tree. - if record.tree_path.trim_end_matches('/') != tree.path().trim_end_matches('/') { - return Err(DeployAloneRefusal::TreePathDrift { - recorded: record.tree_path.clone(), - current: tree.path().to_string(), - }); - } - let current_output_dir = output_dir.map(|p| p.to_string_lossy().into_owned()); - if record.output_dir != current_output_dir { - return Err(DeployAloneRefusal::OutputDirMismatch); - } - if image.is_none() { - return Err(DeployAloneRefusal::ImageMissing); + if record.branch != head_branch { + return Err(DeployAloneRefusal::HeadMismatch { + recorded: record.branch.clone(), + current: head_branch.to_string(), + }); + } + // Trailing slashes are normalized away: a config edit that only adds + // or drops one does not move the tree. + if record.tree_path.trim_end_matches('/') != tree.path().trim_end_matches('/') { + return Err(DeployAloneRefusal::TreePathDrift { + recorded: record.tree_path.clone(), + current: tree.path().to_string(), + }); + } + let current_output_dir = output_dir.map(|p| p.to_string_lossy().into_owned()); + if record.output_dir != current_output_dir { + return Err(DeployAloneRefusal::OutputDirMismatch); + } + if image.is_none() { + return Err(DeployAloneRefusal::ImageMissing); + } + Ok(()) + } + + /// Runs all readiness probes for `tree` and composes them into a + /// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — + /// resolving it (via git) is the caller's job, keeping these probes pure. + /// + /// `for_branch`, when set, is the branch deploy-alone should be judged + /// against (the branch typed on KwOps). `current_branch` still reports + /// the real HEAD so the UI can show both. + #[cfg(unix)] + #[expect(clippy::too_many_arguments)] + pub fn evaluate_readiness( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + shell: &dyn ShellTrait, + history: &dyn KwHistoryStore, + kernel_tree_id: &str, + tree: &KernelTree, + head_branch: &str, + for_branch: Option<&str>, + ) -> Result { + let tree_path = Path::new(tree.path()); + let kw_binary = Self::probe_kw_binary(env, shell); + let output_dir = Self::resolve_output_dir(fs, env, tree_path)?; + let tree_status = Self::probe_tree(fs, tree_path, output_dir.as_deref()); + let arch = match &tree_status { + TreeReadiness::Ready { arch } => arch.clone(), + _ => None, + }; + let kernel_image = Self::find_newest_kernel_image( + fs, + output_dir.as_deref().unwrap_or(tree_path), + arch.as_deref(), + ); + let lookup_branch = match for_branch + .map(str::trim) + .filter(|branch| !branch.is_empty()) + { + Some(branch) => branch, + None => head_branch, + }; + let (build_record, latest_build) = history.build_records(kernel_tree_id, lookup_branch)?; + // The tree's current state is part of the verdict: a stale image and a + // matching record must not green-light a deploy on a tree that has + // since lost its .config, .kw/, or kernel-root files. + let deploy_alone = match &tree_status { + TreeReadiness::Ready { .. } => Self::check_deploy_alone( + build_record.as_ref(), + latest_build.as_ref(), + tree, + lookup_branch, + output_dir.as_deref(), + kernel_image.as_deref(), + ), + other => Err(DeployAloneRefusal::TreeNotReady(other.clone())), + }; + Ok(KwReadiness { + kw_binary, + tree: tree_status, + output_dir, + deploy_alone, + current_branch: { + let trimmed = head_branch.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }, + deploy_remote: remote::resolve_deploy_remote(fs, env, tree_path), + boot_once: Self::probe_boot_once(fs, env, tree_path), + }) } - Ok(()) -} - -/// Runs all readiness probes for `tree` and composes them into a -/// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — -/// resolving it (via git) is the caller's job, keeping these probes pure. -/// -/// `for_branch`, when set, is the branch deploy-alone should be judged -/// against (the branch typed on KwOps). `current_branch` still reports -/// the real HEAD so the UI can show both. -#[cfg(unix)] -#[expect(clippy::too_many_arguments)] -pub fn evaluate_readiness( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - shell: &dyn ShellTrait, - history: &dyn KwHistoryStore, - kernel_tree_id: &str, - tree: &KernelTree, - head_branch: &str, - for_branch: Option<&str>, -) -> Result { - let tree_path = Path::new(tree.path()); - let kw_binary = probe_kw_binary(env, shell); - let output_dir = resolve_output_dir(fs, env, tree_path)?; - let tree_status = probe_tree(fs, tree_path, output_dir.as_deref()); - let arch = match &tree_status { - TreeReadiness::Ready { arch } => arch.clone(), - _ => None, - }; - let kernel_image = find_newest_kernel_image( - fs, - output_dir.as_deref().unwrap_or(tree_path), - arch.as_deref(), - ); - let lookup_branch = match for_branch - .map(str::trim) - .filter(|branch| !branch.is_empty()) - { - Some(branch) => branch, - None => head_branch, - }; - let (build_record, latest_build) = history.build_records(kernel_tree_id, lookup_branch)?; - // The tree's current state is part of the verdict: a stale image and a - // matching record must not green-light a deploy on a tree that has - // since lost its .config, .kw/, or kernel-root files. - let deploy_alone = match &tree_status { - TreeReadiness::Ready { .. } => check_deploy_alone( - build_record.as_ref(), - latest_build.as_ref(), - tree, - lookup_branch, - output_dir.as_deref(), - kernel_image.as_deref(), - ), - other => Err(DeployAloneRefusal::TreeNotReady(other.clone())), - }; - Ok(KwReadiness { - kw_binary, - tree: tree_status, - output_dir, - deploy_alone, - current_branch: { - let trimmed = head_branch.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }, - deploy_remote: remote::resolve_deploy_remote(fs, env, tree_path), - boot_once: probe_boot_once(fs, env, tree_path), - }) } #[cfg(test)] @@ -648,7 +657,7 @@ dtb_copy_pattern={broadcom,rockchip}#kept no_equals_line cross_compile= last_line_without_newline=yes"; - let parsed = parse_kw_config(content); + let parsed = ReadinessService::parse_kw_config(content); assert_eq!(parsed.get("arch"), Some(&"x86_64".to_string())); assert_eq!(parsed.get("cpu_scaling_factor"), Some(&"100".to_string())); @@ -672,7 +681,7 @@ last_line_without_newline=yes"; let dir = TempDir::new("kernel-root-complete"); make_kernel_root(dir.path()); - assert!(is_kernel_root(&OsFileSystem, dir.path())); + assert!(ReadinessService::is_kernel_root(&OsFileSystem, dir.path())); } #[test] @@ -685,7 +694,7 @@ last_line_without_newline=yes"; .unwrap(); assert!( - !is_kernel_root(&OsFileSystem, dir.path()), + !ReadinessService::is_kernel_root(&OsFileSystem, dir.path()), "missing {member} should fail the check" ); } @@ -697,7 +706,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::Missing, - probe_tree(&OsFileSystem, &dir.path().join("nope"), None) + ReadinessService::probe_tree(&OsFileSystem, &dir.path().join("nope"), None) ); } @@ -707,7 +716,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::NotAKernelRoot, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -718,7 +727,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::MissingKwDir, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -730,7 +739,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::MissingKernelConfig, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -740,7 +749,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::Ready { arch: None }, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -753,7 +762,7 @@ last_line_without_newline=yes"; TreeReadiness::Ready { arch: Some("arm64".to_string()) }, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -766,12 +775,12 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::MissingKernelConfig, - probe_tree(&OsFileSystem, dir.path(), Some(out.path())) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())) ); fs::write(out.path().join(".config"), "").unwrap(); assert!(matches!( - probe_tree(&OsFileSystem, dir.path(), Some(out.path())), + ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())), TreeReadiness::Ready { .. } )); } @@ -784,7 +793,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::InTreeBuildArtifacts, - probe_tree(&OsFileSystem, dir.path(), Some(out.path())) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())) ); } @@ -798,7 +807,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::InTreeBuildArtifacts, - probe_tree(&OsFileSystem, dir.path(), Some(out.path())) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())) ); } @@ -809,7 +818,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::Ready { arch: None }, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -820,7 +829,7 @@ last_line_without_newline=yes"; assert_eq!( Some("x86".to_string()), - read_build_arch(&OsFileSystem, dir.path()) + ReadinessService::read_build_arch(&OsFileSystem, dir.path()) ); } @@ -829,7 +838,10 @@ last_line_without_newline=yes"; // Missing file, missing key, commented key, and empty value all map // to kw's "unset" semantics. let no_file = make_ready_tree("arch-no-file"); - assert_eq!(None, read_build_arch(&OsFileSystem, no_file.path())); + assert_eq!( + None, + ReadinessService::read_build_arch(&OsFileSystem, no_file.path()) + ); let no_key = make_ready_tree("arch-no-key"); fs::write( @@ -837,15 +849,24 @@ last_line_without_newline=yes"; "cpu_scaling_factor=100\n", ) .unwrap(); - assert_eq!(None, read_build_arch(&OsFileSystem, no_key.path())); + assert_eq!( + None, + ReadinessService::read_build_arch(&OsFileSystem, no_key.path()) + ); let commented = make_ready_tree("arch-commented"); fs::write(commented.path().join(".kw/build.config"), "#arch=riscv\n").unwrap(); - assert_eq!(None, read_build_arch(&OsFileSystem, commented.path())); + assert_eq!( + None, + ReadinessService::read_build_arch(&OsFileSystem, commented.path()) + ); let empty = make_ready_tree("arch-empty"); fs::write(empty.path().join(".kw/build.config"), "arch=\n").unwrap(); - assert_eq!(None, read_build_arch(&OsFileSystem, empty.path())); + assert_eq!( + None, + ReadinessService::read_build_arch(&OsFileSystem, empty.path()) + ); } /// Creates a file whose mtime is `mtime_secs` seconds after the epoch, @@ -866,7 +887,7 @@ last_line_without_newline=yes"; assert_eq!( None, - resolve_output_dir(&OsFileSystem, &env, dir.path()).unwrap() + ReadinessService::resolve_output_dir(&OsFileSystem, &env, dir.path()).unwrap() ); } @@ -883,7 +904,8 @@ last_line_without_newline=yes"; .withf(|key| key == "XDG_CACHE_HOME") .returning(|_| Ok("/xdg".to_string())); - let resolved = resolve_output_dir(&fs, &env, Path::new("/home/user/linux")).unwrap(); + let resolved = + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/home/user/linux")).unwrap(); assert_eq!( Some(PathBuf::from("/xdg/kw/envs/L2hvbWUvdXNlci9saW51eA==/minix")), @@ -905,7 +927,8 @@ last_line_without_newline=yes"; .withf(|key| key == "HOME") .returning(|_| Ok("/home/user".to_string())); - let resolved = resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap(); + let resolved = + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap(); assert_eq!( Some( @@ -933,7 +956,8 @@ last_line_without_newline=yes"; .withf(|key| key == "HOME") .returning(|_| Ok("/home/user".to_string())); - let resolved = resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap(); + let resolved = + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap(); assert_eq!( Some( @@ -958,7 +982,9 @@ last_line_without_newline=yes"; .withf(|key| key == "XDG_CACHE_HOME") .returning(|_| Ok("/xdg".to_string())); - let resolved = resolve_output_dir(&fs, &env, Path::new("/home/user/linux/")).unwrap(); + let resolved = + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/home/user/linux/")) + .unwrap(); assert_eq!( Some(PathBuf::from("/xdg/kw/envs/L2hvbWUvdXNlci9saW51eA==/minix")), @@ -976,7 +1002,7 @@ last_line_without_newline=yes"; assert_eq!( None, - resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap() + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap() ); } @@ -989,7 +1015,7 @@ last_line_without_newline=yes"; }); let env = MockEnvTrait::new(); - assert!(resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); + assert!(ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); } #[test] @@ -1002,7 +1028,7 @@ last_line_without_newline=yes"; env.expect_var() .returning(|_| Err(std::env::VarError::NotPresent.into())); - assert!(resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); + assert!(ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); } #[test] @@ -1018,7 +1044,7 @@ last_line_without_newline=yes"; assert_eq!( Some(boot.join("Image")), - find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) ); } @@ -1036,7 +1062,7 @@ last_line_without_newline=yes"; assert_eq!( Some(arm64_boot.join("Image")), - find_newest_kernel_image(&OsFileSystem, dir.path(), None) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), None) ); } @@ -1052,7 +1078,7 @@ last_line_without_newline=yes"; assert_eq!( Some(boot.join("zImage")), - find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) ); } @@ -1062,11 +1088,11 @@ last_line_without_newline=yes"; // No image anywhere yet: the fixture has an empty arch/ dir. assert_eq!( None, - find_newest_kernel_image(&OsFileSystem, dir.path(), None) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), None) ); assert_eq!( None, - find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) ); let boot = dir.path().join("arch/x86/boot"); @@ -1076,11 +1102,11 @@ last_line_without_newline=yes"; assert_eq!( None, - find_newest_kernel_image(&OsFileSystem, dir.path(), None) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), None) ); assert_eq!( None, - find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), Some("x86")) ); } @@ -1093,7 +1119,7 @@ last_line_without_newline=yes"; assert_eq!( Some("6.17.0-rc1".to_string()), - read_kernelrelease(&OsFileSystem, dir.path()) + ReadinessService::read_kernelrelease(&OsFileSystem, dir.path()) ); } @@ -1101,12 +1127,18 @@ last_line_without_newline=yes"; fn kernelrelease_is_none_without_a_release_file() { let dir = make_ready_tree("kernelrelease-missing"); - assert_eq!(None, read_kernelrelease(&OsFileSystem, dir.path())); + assert_eq!( + None, + ReadinessService::read_kernelrelease(&OsFileSystem, dir.path()) + ); let config_dir = dir.path().join("include").join("config"); fs::create_dir_all(&config_dir).unwrap(); fs::write(config_dir.join("kernel.release"), "\n").unwrap(); - assert_eq!(None, read_kernelrelease(&OsFileSystem, dir.path())); + assert_eq!( + None, + ReadinessService::read_kernelrelease(&OsFileSystem, dir.path()) + ); } fn shell_output(stdout: &str, success: bool) -> ShellOutput { @@ -1150,7 +1182,7 @@ last_line_without_newline=yes"; let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); - let probe = probe_kw_binary(&env, &shell); + let probe = ReadinessService::probe_kw_binary(&env, &shell); assert!(!probe.available); assert_eq!(None, probe.version_line); @@ -1180,7 +1212,7 @@ last_line_without_newline=yes"; .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) .returning(move |_| Ok(shell_output(str::from_utf8(&stdout_bytes).unwrap(), true))); - let probe = probe_kw_binary(&env, &shell); + let probe = ReadinessService::probe_kw_binary(&env, &shell); assert!(probe.available, "for version output {stdout:?}"); assert_eq!(expected, probe.check, "for version output {stdout:?}"); @@ -1199,7 +1231,7 @@ last_line_without_newline=yes"; )) }); - let probe = probe_kw_binary(&env, &shell); + let probe = ReadinessService::probe_kw_binary(&env, &shell); assert_eq!(Some("beta-0.9".to_string()), probe.version_line); } @@ -1226,7 +1258,7 @@ last_line_without_newline=yes"; let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| true); - let probe = probe_kw_binary(&env, &shell); + let probe = ReadinessService::probe_kw_binary(&env, &shell); assert!(probe.available); assert_eq!(None, probe.version_line); @@ -1242,14 +1274,28 @@ last_line_without_newline=yes"; assert_eq!( Err(DeployAloneRefusal::NoBuildRecord), - check_deploy_alone(None, None, &tree, "patchset-x", None, Some(&image)) + ReadinessService::check_deploy_alone( + None, + None, + &tree, + "patchset-x", + None, + Some(&image) + ) ); let mut failed = built_record(dir.path(), "patchset-x"); failed.success = false; assert_eq!( Err(DeployAloneRefusal::LastBuildFailed), - check_deploy_alone(Some(&failed), None, &tree, "patchset-x", None, Some(&image)) + ReadinessService::check_deploy_alone( + Some(&failed), + None, + &tree, + "patchset-x", + None, + Some(&image) + ) ); } @@ -1267,7 +1313,14 @@ last_line_without_newline=yes"; recorded: "patchset-x".to_string(), current: "master".to_string(), }), - check_deploy_alone(Some(&record), None, &tree, "master", None, Some(&image)) + ReadinessService::check_deploy_alone( + Some(&record), + None, + &tree, + "master", + None, + Some(&image) + ) ); // No keyed row for the target, but the tree has a latest build @@ -1277,7 +1330,14 @@ last_line_without_newline=yes"; recorded: "patchset-x".to_string(), current: "master".to_string(), }), - check_deploy_alone(None, Some(&record), &tree, "master", None, Some(&image)) + ReadinessService::check_deploy_alone( + None, + Some(&record), + &tree, + "master", + None, + Some(&image) + ) ); // The config repointed the same tree id at another path. @@ -1287,7 +1347,7 @@ last_line_without_newline=yes"; recorded: dir.path().to_str().unwrap().to_string(), current: "/elsewhere/linux".to_string(), }), - check_deploy_alone( + ReadinessService::check_deploy_alone( Some(&record), None, &moved_tree, @@ -1300,7 +1360,7 @@ last_line_without_newline=yes"; // The active kw env changed since the build. assert_eq!( Err(DeployAloneRefusal::OutputDirMismatch), - check_deploy_alone( + ReadinessService::check_deploy_alone( Some(&record), None, &tree, @@ -1313,12 +1373,26 @@ last_line_without_newline=yes"; // The image the build produced is gone. assert_eq!( Err(DeployAloneRefusal::ImageMissing), - check_deploy_alone(Some(&record), None, &tree, "patchset-x", None, None) + ReadinessService::check_deploy_alone( + Some(&record), + None, + &tree, + "patchset-x", + None, + None + ) ); assert_eq!( Ok(()), - check_deploy_alone(Some(&record), None, &tree, "patchset-x", None, Some(&image)) + ReadinessService::check_deploy_alone( + Some(&record), + None, + &tree, + "patchset-x", + None, + Some(&image) + ) ); // A trailing-slash-only difference is the same tree, not drift. @@ -1326,7 +1400,7 @@ last_line_without_newline=yes"; slashed.tree_path = format!("{}/", dir.path().to_str().unwrap()); assert_eq!( Ok(()), - check_deploy_alone( + ReadinessService::check_deploy_alone( Some(&slashed), None, &tree, @@ -1346,7 +1420,7 @@ last_line_without_newline=yes"; assert_eq!( Err(DeployAloneRefusal::LastBuildFailed), - check_deploy_alone(Some(&failed), None, &tree, "master", None, None) + ReadinessService::check_deploy_alone(Some(&failed), None, &tree, "master", None, None) ); } @@ -1385,7 +1459,7 @@ last_line_without_newline=yes"; .returning(|_| Ok(shell_output("0.10.0\n", true))); let tree = kernel_tree(dir.path()); - let readiness = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1432,7 +1506,7 @@ last_line_without_newline=yes"; shell.expect_execute().times(0); let tree = kernel_tree(&missing); - let readiness = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1475,7 +1549,7 @@ last_line_without_newline=yes"; shell.expect_execute().times(0); let tree = kernel_tree(dir.path()); - let readiness = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1513,7 +1587,7 @@ last_line_without_newline=yes"; let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); let tree = kernel_tree(dir.path()); - let readiness = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1551,7 +1625,7 @@ last_line_without_newline=yes"; shell.expect_execute().times(0); let tree = kernel_tree(dir.path()); - let on_head = evaluate_readiness( + let on_head = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1571,7 +1645,7 @@ last_line_without_newline=yes"; on_head.deploy_alone ); - let for_typed = evaluate_readiness( + let for_typed = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1598,7 +1672,7 @@ last_line_without_newline=yes"; let env = MockEnvTrait::new(); assert_eq!( BootOnceState::Off, - probe_boot_once(&OsFileSystem, &env, off.path()) + ReadinessService::probe_boot_once(&OsFileSystem, &env, off.path()) ); let on = make_ready_tree("boot-once-on"); @@ -1609,7 +1683,7 @@ last_line_without_newline=yes"; .unwrap(); assert_eq!( BootOnceState::On, - probe_boot_once(&OsFileSystem, &env, on.path()) + ReadinessService::probe_boot_once(&OsFileSystem, &env, on.path()) ); } @@ -1621,7 +1695,7 @@ last_line_without_newline=yes"; .returning(|_| Err(std::env::VarError::NotPresent.into())); assert_eq!( BootOnceState::Unknown, - probe_boot_once(&OsFileSystem, &env, empty.path()) + ReadinessService::probe_boot_once(&OsFileSystem, &env, empty.path()) ); let weird = make_ready_tree("boot-once-weird"); @@ -1632,7 +1706,7 @@ last_line_without_newline=yes"; .unwrap(); assert_eq!( BootOnceState::Unknown, - probe_boot_once(&OsFileSystem, &MockEnvTrait::new(), weird.path()) + ReadinessService::probe_boot_once(&OsFileSystem, &MockEnvTrait::new(), weird.path()) ); } @@ -1656,7 +1730,7 @@ last_line_without_newline=yes"; assert_eq!( BootOnceState::Off, - probe_boot_once(&fs, &env, Path::new("/kernel")) + ReadinessService::probe_boot_once(&fs, &env, Path::new("/kernel")) ); } @@ -1675,7 +1749,7 @@ last_line_without_newline=yes"; assert_eq!( BootOnceState::Unknown, - probe_boot_once(&fs, &env, Path::new("/kernel")) + ReadinessService::probe_boot_once(&fs, &env, Path::new("/kernel")) ); } @@ -1696,7 +1770,7 @@ last_line_without_newline=yes"; assert_eq!( BootOnceState::Off, - probe_boot_once(&fs, &env, Path::new("/kernel")) + ReadinessService::probe_boot_once(&fs, &env, Path::new("/kernel")) ); } } From e005306690a0491d927709dc934d0ecd86e30986 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:02:40 -0300 Subject: [PATCH 19/61] refactor(kw): group argv, remote and log-scan helpers into services KwArgvService, RemoteConfigService, and LogScanService hold the former free functions as associated functions. Helpers that did not start with a verb are now find_reserved_option, find_reserved_long_option, find_unique_long_abbrev, find_reserved_short_cluster, find_exact_short, is_value_attached, read_remote_from_file, resolve_xdg_remote_config, find_first_error, and check_grub_missed_release. No tests removed. Signed-off-by: lorenzoberts --- src/app/view_model.rs | 5 +- src/kw/actor/mod.rs | 15 +- src/kw/argv.rs | 381 ++++++++++++++++++++++-------------------- src/kw/log_scan.rs | 195 +++++++++++---------- src/kw/readiness.rs | 2 +- src/kw/remote.rs | 303 +++++++++++++++++---------------- src/kw/status.rs | 2 +- 7 files changed, 475 insertions(+), 428 deletions(-) diff --git a/src/app/view_model.rs b/src/app/view_model.rs index a685b54..96bb588 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -377,7 +377,7 @@ fn project_kw_ops(state: &AppState) -> KwOpsViewModel { }, command: format!( "kw {}", - argv::build_argv(&ops.extra_arg_tokens_for_preview()).join(" ") + argv::KwArgvService::build_argv(&ops.extra_arg_tokens_for_preview()).join(" ") ), start_label, cancel_label, @@ -454,7 +454,8 @@ fn format_deploy_command( match remote { Ok(remote) => format!( "kw {}", - argv::deploy_argv(&remote.endpoint(), reboot, force, extra_args).join(" ") + argv::KwArgvService::deploy_argv(&remote.endpoint(), reboot, force, extra_args) + .join(" ") ), Err(_) => "(no remote)".to_string(), } diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index e0be2b3..fe4da77 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -506,7 +506,8 @@ impl KwActor { "build-{}.log", chrono::Utc::now().format("%Y%m%d-%H%M%S-%3f") )); - let cmd = ShellCommand::new("kw").args(argv::build_argv(&request.extra_args)); + let cmd = + ShellCommand::new("kw").args(argv::KwArgvService::build_argv(&request.extra_args)); let cwd = PathBuf::from(request.tree.path()); let process = self.process.spawn(&cmd, &cwd, &log_path)?; Ok((process, log_path)) @@ -524,7 +525,7 @@ impl KwActor { "deploy-{}.log", chrono::Utc::now().format("%Y%m%d-%H%M%S-%3f") )); - let cmd = ShellCommand::new("kw").args(argv::deploy_argv( + let cmd = ShellCommand::new("kw").args(argv::KwArgvService::deploy_argv( &deploy.remote.endpoint(), deploy.options.reboot, deploy.options.force, @@ -573,7 +574,9 @@ impl KwActor { .as_ref() .and_then(|deploy| deploy.kernelrelease.as_deref()); self.read_job_log(&job.log_path) - .map(|log| log_scan::deploy_warnings(&log, kernelrelease)) + .map(|log| { + log_scan::LogScanService::deploy_warnings(&log, kernelrelease) + }) .unwrap_or_default() } else { Vec::new() @@ -605,7 +608,7 @@ impl KwActor { exit_code: exit.code(), first_error: self .read_job_log(&job.log_path) - .and_then(|log| log_scan::first_error(&log)), + .and_then(|log| log_scan::LogScanService::find_first_error(&log)), log_path: job.log_path, } } @@ -617,7 +620,7 @@ impl KwActor { exit_code: None, first_error: self .read_job_log(&job.log_path) - .and_then(|log| log_scan::first_error(&log)), + .and_then(|log| log_scan::LogScanService::find_first_error(&log)), log_path: job.log_path, } } @@ -921,7 +924,7 @@ fn prepare_deploy_blocking( force: true, boot_once_acknowledged: false, }); - let remote = remote::resolve_deploy_remote(fs, env, tree_path) + let remote = remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path) .map_err(KwStartError::RemoteUnresolved)?; let mut kernelrelease = None; if kind == KwJobKind::Deploy { diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 343e34e..307da56 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -105,188 +105,195 @@ const DEPLOY_RESERVED: &[ReservedOption] = &[ ReservedOption::new(&["--from-sha"], true), ]; -/// The argv for a build job: `kw build ` (reserved extras stripped). -pub fn build_argv(extra_args: &[String]) -> Vec { - merge_extra_args(&["build"], BUILD_RESERVED, extra_args) -} - -/// The argv for a deploy job: `kw deploy --remote -/// --no-reboot|--reboot [--force] `. Reserved extras are -/// stripped so the injected remote, reboot, and force flags win, and -/// so build-only extras (shared KwOps field) cannot fail kw deploy's -/// getopt. `--force` is omitted entirely when `force` is false rather -/// than passing a no-op, because kw has no `--no-force`. -pub fn deploy_argv( - endpoint: &str, - reboot: bool, - force: bool, - extra_args: &[String], -) -> Vec { - let mut base = vec![ - "deploy", - "--remote", - endpoint, - if reboot { "--reboot" } else { "--no-reboot" }, - ]; - if force { - base.push("--force"); - } - merge_extra_args(&base, DEPLOY_RESERVED, extra_args) -} - -/// Appends user-supplied extra args to `base`, stripping every token that -/// would override a reserved option. GNU getopt forms are stripped too: -/// `--name=value`, `--name value`, unique long abbreviations, bundled -/// shorts (`-rf`), and attached short values (`-Fpkg.kw.tar`). A mixed -/// short cluster that contains any reserved flag is dropped whole, so -/// `-uVALUE` cannot be rewritten into a leftover `-VALUE`. All other -/// extras pass through in order. -pub fn merge_extra_args( - base: &[&str], - reserved: &[ReservedOption], - extra_args: &[String], -) -> Vec { - let mut argv: Vec = base.iter().map(|arg| arg.to_string()).collect(); - let mut extras = extra_args.iter(); - while let Some(token) = extras.next() { - match reserved_option_for(reserved, token) { - Some(option) => { - if option.takes_value && !value_is_attached(token, option) { - // `--name value` / `-F value`: the separate value token goes too. - extras.next(); +pub struct KwArgvService; + +impl KwArgvService { + /// The argv for a build job: `kw build ` (reserved extras stripped). + pub fn build_argv(extra_args: &[String]) -> Vec { + Self::merge_extra_args(&["build"], BUILD_RESERVED, extra_args) + } + + /// The argv for a deploy job: `kw deploy --remote + /// --no-reboot|--reboot [--force] `. Reserved extras are + /// stripped so the injected remote, reboot, and force flags win, and + /// so build-only extras (shared KwOps field) cannot fail kw deploy's + /// getopt. `--force` is omitted entirely when `force` is false rather + /// than passing a no-op, because kw has no `--no-force`. + pub fn deploy_argv( + endpoint: &str, + reboot: bool, + force: bool, + extra_args: &[String], + ) -> Vec { + let mut base = vec![ + "deploy", + "--remote", + endpoint, + if reboot { "--reboot" } else { "--no-reboot" }, + ]; + if force { + base.push("--force"); + } + Self::merge_extra_args(&base, DEPLOY_RESERVED, extra_args) + } + + /// Appends user-supplied extra args to `base`, stripping every token that + /// would override a reserved option. GNU getopt forms are stripped too: + /// `--name=value`, `--name value`, unique long abbreviations, bundled + /// shorts (`-rf`), and attached short values (`-Fpkg.kw.tar`). A mixed + /// short cluster that contains any reserved flag is dropped whole, so + /// `-uVALUE` cannot be rewritten into a leftover `-VALUE`. All other + /// extras pass through in order. + pub fn merge_extra_args( + base: &[&str], + reserved: &[ReservedOption], + extra_args: &[String], + ) -> Vec { + let mut argv: Vec = base.iter().map(|arg| arg.to_string()).collect(); + let mut extras = extra_args.iter(); + while let Some(token) = extras.next() { + match Self::find_reserved_option(reserved, token) { + Some(option) => { + if option.takes_value && !Self::is_value_attached(token, option) { + // `--name value` / `-F value`: the separate value token goes too. + extras.next(); + } } + None => argv.push(token.clone()), } - None => argv.push(token.clone()), } + argv + } + + /// Finds the reserved option a token sets, if any: an exact spelling, + /// `--name=value` for a long spelling, a unique GNU getopt abbreviation + /// of a `match_abbrev` long, a bundled reserved short, or an attached + /// short value. The `=` boundary keeps `--alertness` from matching + /// `--alert`; an abbreviation must be a prefix of the reserved spelling, + /// not the other way around. + fn find_reserved_option<'a>( + reserved: &'a [ReservedOption], + token: &str, + ) -> Option<&'a ReservedOption> { + if token.starts_with("--") { + return Self::find_reserved_long_option(reserved, token); + } + Self::find_reserved_short_cluster(reserved, token) } - argv -} - -/// Finds the reserved option a token sets, if any: an exact spelling, -/// `--name=value` for a long spelling, a unique GNU getopt abbreviation -/// of a `match_abbrev` long, a bundled reserved short, or an attached -/// short value. The `=` boundary keeps `--alertness` from matching -/// `--alert`; an abbreviation must be a prefix of the reserved spelling, -/// not the other way around. -fn reserved_option_for<'a>( - reserved: &'a [ReservedOption], - token: &str, -) -> Option<&'a ReservedOption> { - if token.starts_with("--") { - return reserved_long_option(reserved, token); - } - reserved_short_cluster(reserved, token) -} -fn reserved_long_option<'a>( - reserved: &'a [ReservedOption], - token: &str, -) -> Option<&'a ReservedOption> { - let name = token.split_once('=').map_or(token, |(name, _)| name); - reserved - .iter() - .find(|option| { - option + fn find_reserved_long_option<'a>( + reserved: &'a [ReservedOption], + token: &str, + ) -> Option<&'a ReservedOption> { + let name = token.split_once('=').map_or(token, |(name, _)| name); + reserved + .iter() + .find(|option| { + option + .spellings + .iter() + .any(|spelling| spelling.starts_with("--") && name == *spelling) + }) + .or_else(|| Self::find_unique_long_abbrev(reserved, name)) + } + + /// GNU getopt unique-prefix match among options that opted into + /// `match_abbrev`. Uniqueness is against this reserved table, not kw's + /// full option list — a prefix kw would reject as ambiguous can still + /// strip here when only one reserved long matches. Ambiguous prefixes + /// among reserved longs are left alone so a following value token is + /// not eaten. + fn find_unique_long_abbrev<'a>( + reserved: &'a [ReservedOption], + name: &str, + ) -> Option<&'a ReservedOption> { + if name.len() < 3 || !name.starts_with("--") { + return None; + } + let mut found: Option<&ReservedOption> = None; + for option in reserved { + if !option.match_abbrev { + continue; + } + let matches = option .spellings .iter() - .any(|spelling| spelling.starts_with("--") && name == *spelling) - }) - .or_else(|| unique_long_abbrev(reserved, name)) -} - -/// GNU getopt unique-prefix match among options that opted into -/// `match_abbrev`. Uniqueness is against this reserved table, not kw's -/// full option list — a prefix kw would reject as ambiguous can still -/// strip here when only one reserved long matches. Ambiguous prefixes -/// among reserved longs are left alone so a following value token is -/// not eaten. -fn unique_long_abbrev<'a>( - reserved: &'a [ReservedOption], - name: &str, -) -> Option<&'a ReservedOption> { - if name.len() < 3 || !name.starts_with("--") { - return None; - } - let mut found: Option<&ReservedOption> = None; - for option in reserved { - if !option.match_abbrev { - continue; - } - let matches = option - .spellings - .iter() - .any(|spelling| spelling.starts_with("--") && spelling.starts_with(name)); - if !matches { - continue; + .any(|spelling| spelling.starts_with("--") && spelling.starts_with(name)); + if !matches { + continue; + } + match found { + None => found = Some(option), + Some(previous) if std::ptr::eq(previous, option) => {} + Some(_) => return None, + } } - match found { - None => found = Some(option), - Some(previous) if std::ptr::eq(previous, option) => {} - Some(_) => return None, + found + } + + /// A short token that contains any reserved flag: `-f`, `-rf`, `-Fpkg`. + /// Walking leftover letters would turn `-ukernel` into `-kernel`, so a + /// hit drops the whole token. + fn find_reserved_short_cluster<'a>( + reserved: &'a [ReservedOption], + token: &str, + ) -> Option<&'a ReservedOption> { + let body = token.strip_prefix('-')?; + if body.is_empty() || body.starts_with('-') { + return None; } - } - found -} - -/// A short token that contains any reserved flag: `-f`, `-rf`, `-Fpkg`. -/// Walking leftover letters would turn `-ukernel` into `-kernel`, so a -/// hit drops the whole token. -fn reserved_short_cluster<'a>( - reserved: &'a [ReservedOption], - token: &str, -) -> Option<&'a ReservedOption> { - let body = token.strip_prefix('-')?; - if body.is_empty() || body.starts_with('-') { - return None; - } - let chars: Vec = body.chars().collect(); - let mut i = 0; - let mut hit = None; - while i < chars.len() { - let spelling = format!("-{}", chars[i]); - match exact_short(reserved, &spelling) { - Some(option) => { - hit = Some(option); - if option.takes_value { - // Remainder is the attached value; stop so - // `value_is_attached` can see those extra chars. - break; + let chars: Vec = body.chars().collect(); + let mut i = 0; + let mut hit = None; + while i < chars.len() { + let spelling = format!("-{}", chars[i]); + match Self::find_exact_short(reserved, &spelling) { + Some(option) => { + hit = Some(option); + if option.takes_value { + // Remainder is the attached value; stop so + // `is_value_attached` can see those extra chars. + break; + } + i += 1; } - i += 1; + None => i += 1, } - None => i += 1, } + hit } - hit -} -fn exact_short<'a>(reserved: &'a [ReservedOption], spelling: &str) -> Option<&'a ReservedOption> { - reserved - .iter() - .find(|option| option.spellings.contains(&spelling)) -} + fn find_exact_short<'a>( + reserved: &'a [ReservedOption], + spelling: &str, + ) -> Option<&'a ReservedOption> { + reserved + .iter() + .find(|option| option.spellings.contains(&spelling)) + } -fn value_is_attached(token: &str, option: &ReservedOption) -> bool { - if token.contains('=') { - return true; - } - if !option.takes_value { - return false; - } - let Some(body) = token - .strip_prefix('-') - .filter(|body| !body.starts_with('-')) - else { - return false; - }; - let mut chars = body.chars(); - while let Some(ch) = chars.next() { - let spelling = format!("-{ch}"); - if option.spellings.iter().any(|s| *s == spelling) { - return chars.next().is_some(); + fn is_value_attached(token: &str, option: &ReservedOption) -> bool { + if token.contains('=') { + return true; + } + if !option.takes_value { + return false; } + let Some(body) = token + .strip_prefix('-') + .filter(|body| !body.starts_with('-')) + else { + return false; + }; + let mut chars = body.chars(); + while let Some(ch) = chars.next() { + let spelling = format!("-{ch}"); + if option.spellings.iter().any(|s| *s == spelling) { + return chars.next().is_some(); + } + } + false } - false } #[cfg(test)] @@ -299,14 +306,14 @@ mod tests { #[test] fn build_argv_without_extras_is_the_base_command() { - assert_eq!(vec!["build"], build_argv(&[])); + assert_eq!(vec!["build"], KwArgvService::build_argv(&[])); } #[test] fn extras_are_appended_in_order() { assert_eq!( vec!["build", "--verbose", "--ccache", "-j8"], - build_argv(&extras(&["--verbose", "--ccache", "-j8"])) + KwArgvService::build_argv(&extras(&["--verbose", "--ccache", "-j8"])) ); } @@ -314,12 +321,12 @@ mod tests { fn reserved_alert_is_stripped_from_extras() { assert_eq!( vec!["build", "--verbose"], - build_argv(&extras(&["--alert=vv", "--verbose"])) + KwArgvService::build_argv(&extras(&["--alert=vv", "--verbose"])) ); // Separate-token value form: the value token is consumed too. assert_eq!( vec!["build", "--verbose"], - build_argv(&extras(&["--alert", "v", "--verbose"])) + KwArgvService::build_argv(&extras(&["--alert", "v", "--verbose"])) ); } @@ -327,11 +334,11 @@ mod tests { fn save_log_to_is_reserved_in_both_value_forms() { assert_eq!( vec!["build"], - build_argv(&extras(&["--save-log-to=/tmp/x.log"])) + KwArgvService::build_argv(&extras(&["--save-log-to=/tmp/x.log"])) ); assert_eq!( vec!["build"], - build_argv(&extras(&["--save-log-to", "/tmp/x.log"])) + KwArgvService::build_argv(&extras(&["--save-log-to", "/tmp/x.log"])) ); } @@ -340,13 +347,16 @@ mod tests { // `--alertness` only shares a prefix with `--alert`. assert_eq!( vec!["build", "--alertness"], - build_argv(&extras(&["--alertness"])) + KwArgvService::build_argv(&extras(&["--alertness"])) ); } #[test] fn trailing_reserved_option_without_value_is_stripped() { - assert_eq!(vec!["build"], build_argv(&extras(&["--alert"]))); + assert_eq!( + vec!["build"], + KwArgvService::build_argv(&extras(&["--alert"])) + ); } #[test] @@ -355,7 +365,7 @@ mod tests { // redirected to a log file — a hang, not a build. assert_eq!( vec!["build", "--verbose"], - build_argv(&extras(&["--menu", "--verbose"])) + KwArgvService::build_argv(&extras(&["--menu", "--verbose"])) ); } @@ -363,7 +373,7 @@ mod tests { fn tree_mutating_and_hanging_build_flags_are_stripped() { assert_eq!( vec!["build", "--verbose"], - build_argv(&extras(&[ + KwArgvService::build_argv(&extras(&[ "--clean", "--full-cleanup", "--doc", @@ -373,7 +383,10 @@ mod tests { "--verbose", ])) ); - assert_eq!(vec!["build"], build_argv(&extras(&["--from-sha=abc123"]))); + assert_eq!( + vec!["build"], + KwArgvService::build_argv(&extras(&["--from-sha=abc123"])) + ); } #[test] @@ -382,11 +395,11 @@ mod tests { let reserved = [ReservedOption::new(&["--force", "-f"], false)]; assert_eq!( vec!["deploy", "extra"], - merge_extra_args(&["deploy"], &reserved, &extras(&["--force", "extra"])) + KwArgvService::merge_extra_args(&["deploy"], &reserved, &extras(&["--force", "extra"])) ); assert_eq!( vec!["deploy", "extra"], - merge_extra_args(&["deploy"], &reserved, &extras(&["-f", "extra"])) + KwArgvService::merge_extra_args(&["deploy"], &reserved, &extras(&["-f", "extra"])) ); } @@ -395,7 +408,7 @@ mod tests { let reserved = [ReservedOption::new(&["--remote"], true)]; assert_eq!( vec!["deploy", "--no-reboot"], - merge_extra_args( + KwArgvService::merge_extra_args( &["deploy"], &reserved, &extras(&["--remote", "host:22", "--no-reboot"]) @@ -406,14 +419,14 @@ mod tests { const ENDPOINT: &str = "root@lima-ph-dut.internal:22"; fn deploy(extra: &[&str]) -> Vec { - deploy_argv(ENDPOINT, false, true, &extras(extra)) + KwArgvService::deploy_argv(ENDPOINT, false, true, &extras(extra)) } #[test] fn deploy_argv_without_extras_is_remote_no_reboot_force() { assert_eq!( vec!["deploy", "--remote", ENDPOINT, "--no-reboot", "--force",], - deploy_argv(ENDPOINT, false, true, &[]) + KwArgvService::deploy_argv(ENDPOINT, false, true, &[]) ); } @@ -421,7 +434,7 @@ mod tests { fn deploy_argv_reboot_and_unforced_swap_the_injected_flags() { assert_eq!( vec!["deploy", "--remote", ENDPOINT, "--reboot"], - deploy_argv(ENDPOINT, true, false, &[]) + KwArgvService::deploy_argv(ENDPOINT, true, false, &[]) ); } @@ -671,7 +684,7 @@ mod tests { fn build_strips_unique_long_abbreviations_of_reserved_flags() { assert_eq!( vec!["build", "--verbose"], - build_argv(&extras(&["--men", "--from", "abc123", "--verbose"])) + KwArgvService::build_argv(&extras(&["--men", "--from", "abc123", "--verbose"])) ); } @@ -681,7 +694,7 @@ mod tests { // otherwise chain into `kw deploy --help` (exit 22). assert_eq!( vec!["build", "--verbose"], - build_argv(&extras(&["--help", "-h", "--verbose"])) + KwArgvService::build_argv(&extras(&["--help", "-h", "--verbose"])) ); assert_eq!( vec![ diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index b310cf4..75fedd8 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -19,84 +19,93 @@ const DEPLOY_WARNING_MARKERS: [&str; 4] = [ "kw did not find grub.cfg", ]; -/// The first line of a failed job's log that names what went wrong: a -/// compiler/linker `error:`, a modpost `ERROR:`, a kbuild `*** ...` -/// banner (e.g. "The source tree is not clean"), or a git/kw `error:`. -/// Falls back to the last `make: *** ... Error N` line, which only names -/// the failing target. Kbuild prints the real cause first and a cascade -/// of make errors after it, so the first match is the useful one. -pub fn first_error(log: &str) -> Option { - log.lines() - .map(str::trim) - .find(|line| is_error_line(line)) - .or_else(|| log.lines().map(str::trim).rfind(|line| is_make_error(line))) - .map(cap_line) -} +pub struct LogScanService; + +impl LogScanService { + /// The first line of a failed job's log that names what went wrong: a + /// compiler/linker `error:`, a modpost `ERROR:`, a kbuild `*** ...` + /// banner (e.g. "The source tree is not clean"), or a git/kw `error:`. + /// Falls back to the last `make: *** ... Error N` line, which only names + /// the failing target. Kbuild prints the real cause first and a cascade + /// of make errors after it, so the first match is the useful one. + pub fn find_first_error(log: &str) -> Option { + log.lines() + .map(str::trim) + .find(|line| Self::is_error_line(line)) + .or_else(|| { + log.lines() + .map(str::trim) + .rfind(|line| Self::is_make_error(line)) + }) + .map(Self::cap_line) + } -/// Known failures in a deploy log that `kw deploy` exits 0 through, at -/// most [`MAX_DEPLOY_WARNINGS`]. With `kernelrelease`, a GRUB update that -/// did not list that kernel is reported too: kw installs arm64 kernels as -/// `Image-`, which Debian's `10_linux` does not pick up, so the -/// deployed kernel is never bootable from the menu. -pub fn deploy_warnings(log: &str, kernelrelease: Option<&str>) -> Vec { - let mut warnings: Vec = Vec::new(); - for line in log.lines().map(str::trim) { - if DEPLOY_WARNING_MARKERS - .iter() - .any(|marker| line.contains(marker)) - { - let line = cap_line(line); - if !warnings.contains(&line) { - warnings.push(line); + /// Known failures in a deploy log that `kw deploy` exits 0 through, at + /// most [`MAX_DEPLOY_WARNINGS`]. With `kernelrelease`, a GRUB update that + /// did not list that kernel is reported too: kw installs arm64 kernels as + /// `Image-`, which Debian's `10_linux` does not pick up, so the + /// deployed kernel is never bootable from the menu. + pub fn deploy_warnings(log: &str, kernelrelease: Option<&str>) -> Vec { + let mut warnings: Vec = Vec::new(); + for line in log.lines().map(str::trim) { + if DEPLOY_WARNING_MARKERS + .iter() + .any(|marker| line.contains(marker)) + { + let line = Self::cap_line(line); + if !warnings.contains(&line) { + warnings.push(line); + } } } + let unlisted = + kernelrelease.filter(|release| Self::check_grub_missed_release(log, release)); + warnings.truncate(MAX_DEPLOY_WARNINGS - usize::from(unlisted.is_some())); + if let Some(release) = unlisted { + warnings.push(format!("GRUB did not list kernel {release}")); + } + warnings } - let unlisted = kernelrelease.filter(|release| grub_missed_release(log, release)); - warnings.truncate(MAX_DEPLOY_WARNINGS - usize::from(unlisted.is_some())); - if let Some(release) = unlisted { - warnings.push(format!("GRUB did not list kernel {release}")); - } - warnings -} -fn is_error_line(line: &str) -> bool { - line.contains("fatal error:") - || line.contains(" error:") - || line.starts_with("error:") - || line.starts_with("ERROR:") - || line - .strip_prefix("*** ") - .is_some_and(|banner| !banner.trim().is_empty()) -} + fn is_error_line(line: &str) -> bool { + line.contains("fatal error:") + || line.contains(" error:") + || line.starts_with("error:") + || line.starts_with("ERROR:") + || line + .strip_prefix("*** ") + .is_some_and(|banner| !banner.trim().is_empty()) + } -/// `make[N]: *** [target] Error N`, the summary make prints per failing -/// level. -fn is_make_error(line: &str) -> bool { - line.starts_with("make") - && line.contains(": *** ") - && line - .rsplit_once("Error ") - .is_some_and(|(_, code)| !code.is_empty() && code.chars().all(|c| c.is_ascii_digit())) -} + /// `make[N]: *** [target] Error N`, the summary make prints per failing + /// level. + fn is_make_error(line: &str) -> bool { + line.starts_with("make") + && line.contains(": *** ") + && line.rsplit_once("Error ").is_some_and(|(_, code)| { + !code.is_empty() && code.chars().all(|c| c.is_ascii_digit()) + }) + } -/// GRUB ran (`Generating grub configuration file`) but no `Found linux -/// image:` line names an image for `release`. Matched on the `-` -/// suffix so `7.2.0-rc6` is not satisfied by `Image-7.2.0-rc6-phboot`. -fn grub_missed_release(log: &str, release: &str) -> bool { - if !log.contains("Generating grub configuration file") { - return false; + /// GRUB ran (`Generating grub configuration file`) but no `Found linux + /// image:` line names an image for `release`. Matched on the `-` + /// suffix so `7.2.0-rc6` is not satisfied by `Image-7.2.0-rc6-phboot`. + fn check_grub_missed_release(log: &str, release: &str) -> bool { + if !log.contains("Generating grub configuration file") { + return false; + } + let suffix = format!("-{release}"); + !log.lines().map(str::trim).any(|line| { + line.strip_prefix("Found linux image:") + .is_some_and(|image| image.trim().ends_with(&suffix)) + }) } - let suffix = format!("-{release}"); - !log.lines().map(str::trim).any(|line| { - line.strip_prefix("Found linux image:") - .is_some_and(|image| image.trim().ends_with(&suffix)) - }) -} -fn cap_line(line: &str) -> String { - match line.char_indices().nth(MAX_LINE_CHARS) { - Some((index, _)) => format!("{}…", &line[..index]), - None => line.to_string(), + fn cap_line(line: &str) -> String { + match line.char_indices().nth(MAX_LINE_CHARS) { + Some((index, _)) => format!("{}…", &line[..index]), + None => line.to_string(), + } } } @@ -182,7 +191,7 @@ done "*** The source tree is not clean, please run 'make ARCH=arm64 mrproper'" .to_string() ), - first_error(UNCLEAN_TREE_BUILD_LOG) + LogScanService::find_first_error(UNCLEAN_TREE_BUILD_LOG) ); } @@ -193,7 +202,7 @@ done "/opt/ph-lab/work/linux/init/main.c:1691:2: error: #error M4 broken-build fixture" .to_string() ), - first_error(COMPILE_ERROR_BUILD_LOG) + LogScanService::find_first_error(COMPILE_ERROR_BUILD_LOG) ); } @@ -201,17 +210,21 @@ done fn first_error_recognizes_modpost_and_git_errors() { assert_eq!( Some("ERROR: modpost: \"foo\" [drivers/bar.ko] undefined!".to_string()), - first_error( + LogScanService::find_first_error( " MODPOST Module.symvers\nERROR: modpost: \"foo\" [drivers/bar.ko] undefined!\n" ) ); assert_eq!( Some("error: pathspec 'nope' did not match any file(s) known to git".to_string()), - first_error("error: pathspec 'nope' did not match any file(s) known to git\n") + LogScanService::find_first_error( + "error: pathspec 'nope' did not match any file(s) known to git\n" + ) ); assert_eq!( Some("drivers/foo.c:1:10: fatal error: bar.h: No such file or directory".to_string()), - first_error("drivers/foo.c:1:10: fatal error: bar.h: No such file or directory\n") + LogScanService::find_first_error( + "drivers/foo.c:1:10: fatal error: bar.h: No such file or directory\n" + ) ); } @@ -225,20 +238,23 @@ make: *** [Makefile:248: __sub-make] Error 2 "; assert_eq!( Some("make: *** [Makefile:248: __sub-make] Error 2".to_string()), - first_error(log) + LogScanService::find_first_error(log) ); } #[test] fn first_error_is_none_without_any_error_line() { - assert_eq!(None, first_error("")); - assert_eq!(None, first_error(" CC init/main.o\n***\n")); + assert_eq!(None, LogScanService::find_first_error("")); + assert_eq!( + None, + LogScanService::find_first_error(" CC init/main.o\n***\n") + ); } #[test] fn first_error_caps_long_lines() { let line = format!("drivers/x.c:1:1: error: {}", "y".repeat(400)); - let found = first_error(&line).unwrap(); + let found = LogScanService::find_first_error(&line).unwrap(); assert_eq!(MAX_LINE_CHARS + 1, found.chars().count()); assert!(found.ends_with('…')); } @@ -250,7 +266,7 @@ make: *** [Makefile:248: __sub-make] Error 2 "update-initramfs: failed for /boot/initrd.img-7.2.0-rc6+ with 1.".to_string(), "GRUB did not list kernel 7.2.0-rc6+".to_string(), ], - deploy_warnings(TINYCONFIG_DEPLOY_LOG, Some("7.2.0-rc6+")) + LogScanService::deploy_warnings(TINYCONFIG_DEPLOY_LOG, Some("7.2.0-rc6+")) ); } @@ -258,15 +274,17 @@ make: *** [Makefile:248: __sub-make] Error 2 fn deploy_warnings_skip_the_grub_check_without_a_release() { assert_eq!( vec!["update-initramfs: failed for /boot/initrd.img-7.2.0-rc6+ with 1.".to_string()], - deploy_warnings(TINYCONFIG_DEPLOY_LOG, None) + LogScanService::deploy_warnings(TINYCONFIG_DEPLOY_LOG, None) ); } #[test] fn clean_deploy_has_no_warnings() { - assert!( - deploy_warnings(CLEAN_DEPLOY_LOG, Some("7.2.0-rc6-phboot-g2a475abe5df2")).is_empty() - ); + assert!(LogScanService::deploy_warnings( + CLEAN_DEPLOY_LOG, + Some("7.2.0-rc6-phboot-g2a475abe5df2") + ) + .is_empty()); } #[test] @@ -279,14 +297,17 @@ done "; assert_eq!( vec!["GRUB did not list kernel 7.2.0-rc6".to_string()], - deploy_warnings(log, Some("7.2.0-rc6")) + LogScanService::deploy_warnings(log, Some("7.2.0-rc6")) ); } #[test] fn grub_check_is_silent_when_grub_did_not_run() { // systemd-boot / non-GRUB DUTs never print the GRUB banner. - assert!(deploy_warnings("==> Starting build: '7.2.0'\n", Some("7.2.0")).is_empty()); + assert!( + LogScanService::deploy_warnings("==> Starting build: '7.2.0'\n", Some("7.2.0")) + .is_empty() + ); } #[test] @@ -301,14 +322,14 @@ done ==> ERROR: module not found: 'f' kw was unable to set up the first boot "; - let warnings = deploy_warnings(log, None); + let warnings = LogScanService::deploy_warnings(log, None); assert_eq!(MAX_DEPLOY_WARNINGS, warnings.len()); assert_eq!("==> ERROR: module not found: 'a'", warnings[0]); assert_eq!("==> ERROR: module not found: 'e'", warnings[4]); // The unlisted-kernel warning survives the cap. let log = format!("{log}Generating grub configuration file ...\n"); - let warnings = deploy_warnings(&log, Some("7.2.0")); + let warnings = LogScanService::deploy_warnings(&log, Some("7.2.0")); assert_eq!(MAX_DEPLOY_WARNINGS, warnings.len()); assert_eq!("GRUB did not list kernel 7.2.0", warnings[4]); } diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 83de225..1ea7c91 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -545,7 +545,7 @@ impl ReadinessService { Some(trimmed.to_string()) } }, - deploy_remote: remote::resolve_deploy_remote(fs, env, tree_path), + deploy_remote: remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path), boot_once: Self::probe_boot_once(fs, env, tree_path), }) } diff --git a/src/kw/remote.rs b/src/kw/remote.rs index c5af231..4465ef7 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -33,130 +33,6 @@ impl KwRemote { } } -/// Parses kw's ssh-config-like `remote.config`. Blank lines and comments -/// are skipped; `#kw-default=` (anywhere) names the default Host, -/// last occurrence winning; `Host ` starts a stanza; `Hostname`, -/// `Port`, and `User` are read case-insensitively; `IdentityFile` and -/// other keys are tolerated and ignored. Port defaults to 22 when unset. -/// Duplicate Host names keep the last complete stanza. -#[cfg(unix)] -pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { - let mut parsed = ParsedRemoteConfig::default(); - let mut current: Option = None; - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if let Some(name) = trimmed.strip_prefix("#kw-default=") { - let name = name.trim(); - if !name.is_empty() { - parsed.default = Some(name.to_string()); - } - continue; - } - if trimmed.starts_with('#') { - continue; - } - - let Some((keyword, rest)) = split_keyword(trimmed) else { - continue; - }; - if keyword.eq_ignore_ascii_case("host") { - if let Some(host) = current.take().and_then(HostBuilder::finish) { - upsert_host(&mut parsed.hosts, host); - } - let name = rest.split_whitespace().next().unwrap_or(""); - if !name.is_empty() { - current = Some(HostBuilder::new(name)); - } - continue; - } - let Some(builder) = current.as_mut() else { - continue; - }; - if keyword.eq_ignore_ascii_case("hostname") { - if !rest.is_empty() { - builder.hostname = Some(rest.to_string()); - } - } else if keyword.eq_ignore_ascii_case("port") { - if !rest.is_empty() { - builder.port = Some(rest.to_string()); - } - } else if keyword.eq_ignore_ascii_case("user") && !rest.is_empty() { - builder.user = Some(rest.to_string()); - } - } - if let Some(host) = current.and_then(HostBuilder::finish) { - upsert_host(&mut parsed.hosts, host); - } - parsed -} - -/// Picks the deploy remote from a parsed file: the `#kw-default=` Host -/// when set, otherwise the only Host. Multiple hosts with no default, or -/// a default that names no complete Host, are refusals. -#[cfg(unix)] -pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result { - if let Some(name) = parsed.default.as_deref() { - return parsed - .hosts - .iter() - .find(|host| host.name == name) - .cloned() - .ok_or_else(|| RemoteRefusal::DefaultNotFound { - name: name.to_string(), - }); - } - match parsed.hosts.as_slice() { - [] => Err(RemoteRefusal::NoRemotesConfigured), - [only] => Ok(only.clone()), - hosts => Err(RemoteRefusal::NoDefault { count: hosts.len() }), - } -} - -/// Resolves the remote kw deploy will be pointed at. See the module docs -/// for the file lookup order. -#[cfg(unix)] -pub fn resolve_deploy_remote( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, -) -> Result { - let local = tree_path.join(".kw").join("remote.config"); - if fs.is_file(&local) { - return remote_from_file(fs, &local); - } - let Some(global) = xdg_kw_remote_config(env) else { - return Err(RemoteRefusal::NoRemotesConfigured); - }; - if fs.is_file(&global) { - return remote_from_file(fs, &global); - } - Err(RemoteRefusal::NoRemotesConfigured) -} - -#[cfg(unix)] -fn remote_from_file(fs: &dyn FileSystemTrait, path: &Path) -> Result { - let content = fs - .read_to_string(path) - .map_err(|_| RemoteRefusal::NoRemotesConfigured)?; - select_deploy_remote(&parse_remote_config(&content)) -} - -/// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/remote.config`. A set-but-empty -/// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG -/// spec — otherwise the path would be relative to cwd. -#[cfg(unix)] -fn xdg_kw_remote_config(env: &dyn EnvTrait) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), - }; - Some(Path::new(&config_home).join("kw").join("remote.config")) -} - #[cfg(unix)] struct HostBuilder { name: String, @@ -193,21 +69,147 @@ impl HostBuilder { } #[cfg(unix)] -fn split_keyword(line: &str) -> Option<(&str, &str)> { - let keyword_end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); - let keyword = &line[..keyword_end]; - if keyword.is_empty() { - return None; - } - Some((keyword, line[keyword_end..].trim())) -} +pub struct RemoteConfigService; #[cfg(unix)] -fn upsert_host(hosts: &mut Vec, host: KwRemote) { - if let Some(existing) = hosts.iter_mut().find(|entry| entry.name == host.name) { - *existing = host; - } else { - hosts.push(host); +impl RemoteConfigService { + /// Parses kw's ssh-config-like `remote.config`. Blank lines and comments + /// are skipped; `#kw-default=` (anywhere) names the default Host, + /// last occurrence winning; `Host ` starts a stanza; `Hostname`, + /// `Port`, and `User` are read case-insensitively; `IdentityFile` and + /// other keys are tolerated and ignored. Port defaults to 22 when unset. + /// Duplicate Host names keep the last complete stanza. + pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { + let mut parsed = ParsedRemoteConfig::default(); + let mut current: Option = None; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(name) = trimmed.strip_prefix("#kw-default=") { + let name = name.trim(); + if !name.is_empty() { + parsed.default = Some(name.to_string()); + } + continue; + } + if trimmed.starts_with('#') { + continue; + } + + let Some((keyword, rest)) = Self::split_keyword(trimmed) else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + if let Some(host) = current.take().and_then(HostBuilder::finish) { + Self::upsert_host(&mut parsed.hosts, host); + } + let name = rest.split_whitespace().next().unwrap_or(""); + if !name.is_empty() { + current = Some(HostBuilder::new(name)); + } + continue; + } + let Some(builder) = current.as_mut() else { + continue; + }; + if keyword.eq_ignore_ascii_case("hostname") { + if !rest.is_empty() { + builder.hostname = Some(rest.to_string()); + } + } else if keyword.eq_ignore_ascii_case("port") { + if !rest.is_empty() { + builder.port = Some(rest.to_string()); + } + } else if keyword.eq_ignore_ascii_case("user") && !rest.is_empty() { + builder.user = Some(rest.to_string()); + } + } + if let Some(host) = current.and_then(HostBuilder::finish) { + Self::upsert_host(&mut parsed.hosts, host); + } + parsed + } + + /// Picks the deploy remote from a parsed file: the `#kw-default=` Host + /// when set, otherwise the only Host. Multiple hosts with no default, or + /// a default that names no complete Host, are refusals. + pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result { + if let Some(name) = parsed.default.as_deref() { + return parsed + .hosts + .iter() + .find(|host| host.name == name) + .cloned() + .ok_or_else(|| RemoteRefusal::DefaultNotFound { + name: name.to_string(), + }); + } + match parsed.hosts.as_slice() { + [] => Err(RemoteRefusal::NoRemotesConfigured), + [only] => Ok(only.clone()), + hosts => Err(RemoteRefusal::NoDefault { count: hosts.len() }), + } + } + + /// Resolves the remote kw deploy will be pointed at. See the module docs + /// for the file lookup order. + pub fn resolve_deploy_remote( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> Result { + let local = tree_path.join(".kw").join("remote.config"); + if fs.is_file(&local) { + return Self::read_remote_from_file(fs, &local); + } + let Some(global) = Self::resolve_xdg_remote_config(env) else { + return Err(RemoteRefusal::NoRemotesConfigured); + }; + if fs.is_file(&global) { + return Self::read_remote_from_file(fs, &global); + } + Err(RemoteRefusal::NoRemotesConfigured) + } + + fn read_remote_from_file( + fs: &dyn FileSystemTrait, + path: &Path, + ) -> Result { + let content = fs + .read_to_string(path) + .map_err(|_| RemoteRefusal::NoRemotesConfigured)?; + Self::select_deploy_remote(&Self::parse_remote_config(&content)) + } + + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/remote.config`. A set-but-empty + /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG + /// spec — otherwise the path would be relative to cwd. + fn resolve_xdg_remote_config(env: &dyn EnvTrait) -> Option { + let config_home = match env.var("XDG_CONFIG_HOME") { + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.config", env.var("HOME").ok()?), + }; + Some(Path::new(&config_home).join("kw").join("remote.config")) + } + + fn split_keyword(line: &str) -> Option<(&str, &str)> { + let keyword_end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); + let keyword = &line[..keyword_end]; + if keyword.is_empty() { + return None; + } + Some((keyword, line[keyword_end..].trim())) + } + + fn upsert_host(hosts: &mut Vec, host: KwRemote) { + if let Some(existing) = hosts.iter_mut().find(|entry| entry.name == host.name) { + *existing = host; + } else { + hosts.push(host); + } } } @@ -247,7 +249,9 @@ Host ph-dut "; fn choose(content: &str) -> Result { - select_deploy_remote(&parse_remote_config(content)) + RemoteConfigService::select_deploy_remote(&RemoteConfigService::parse_remote_config( + content, + )) } fn remote(name: &str, hostname: &str, port: u16, user: Option<&str>) -> KwRemote { @@ -261,7 +265,7 @@ Host ph-dut #[test] fn plan_fixture_picks_the_default_host() { - let parsed = parse_remote_config(SAMPLE_REMOTE_CONFIG); + let parsed = RemoteConfigService::parse_remote_config(SAMPLE_REMOTE_CONFIG); assert_eq!(Some("arch-test"), parsed.default.as_deref()); assert_eq!( vec![ @@ -390,7 +394,7 @@ Host dut Hostname new Port 2222 "; - let parsed = parse_remote_config(content); + let parsed = RemoteConfigService::parse_remote_config(content); assert_eq!(Some("dut"), parsed.default.as_deref()); assert_eq!(vec![remote("dut", "new", 2222, None)], parsed.hosts); } @@ -515,7 +519,9 @@ Host dut extra ]); let env = env_with(Some("/xdg"), Some("/home/user")); - let chosen = resolve_deploy_remote(&fs, &env, Path::new("/home/user/linux")).unwrap(); + let chosen = + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/home/user/linux")) + .unwrap(); assert_eq!("ph-dut", chosen.name); } @@ -527,7 +533,9 @@ Host dut extra )]); let env = env_with(Some("/xdg"), Some("/home/user")); - let chosen = resolve_deploy_remote(&fs, &env, Path::new("/home/user/linux")).unwrap(); + let chosen = + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/home/user/linux")) + .unwrap(); assert_eq!("root@box:22", chosen.endpoint()); } @@ -539,7 +547,8 @@ Host dut extra )]); let env = env_with(Some(""), Some("/home/user")); - let chosen = resolve_deploy_remote(&fs, &env, Path::new("/kernel")).unwrap(); + let chosen = + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")).unwrap(); assert_eq!("box:22", chosen.endpoint()); } @@ -550,7 +559,7 @@ Host dut extra assert_eq!( Err(RemoteRefusal::NoRemotesConfigured), - resolve_deploy_remote(&fs, &env, Path::new("/kernel")) + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")) ); } @@ -566,7 +575,7 @@ Host dut extra assert_eq!( Err(RemoteRefusal::NoRemotesConfigured), - resolve_deploy_remote(&fs, &env, Path::new("/kernel")) + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")) ); } @@ -585,7 +594,7 @@ Host dut extra assert_eq!( Err(RemoteRefusal::NoRemotesConfigured), - resolve_deploy_remote(&fs, &env, Path::new("/kernel")) + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")) ); } @@ -596,7 +605,7 @@ Host dut extra assert_eq!( Err(RemoteRefusal::NoRemotesConfigured), - resolve_deploy_remote(&fs, &env, Path::new("/kernel")) + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")) ); } } diff --git a/src/kw/status.rs b/src/kw/status.rs index 2bcc18e..89d7274 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -38,7 +38,7 @@ pub enum KwJobStatus { branch: String, log_path: PathBuf, /// Known deploy failures kw exited 0 through (see - /// [`crate::kw::log_scan::deploy_warnings`]). Always empty for a + /// [`crate::kw::log_scan::LogScanService::deploy_warnings`]). Always empty for a /// build-only job. warnings: Vec, }, From 172af3538c5fb447a05810f826bdc9948e0c0816 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:05:13 -0300 Subject: [PATCH 20/61] refactor(kw): move job helpers into KwActor and KwGitService KwGitService holds the worktree probe, branch switch, and HEAD probe. Job running, cancel, and outcome mapping are private KwActor functions, along with prepare_deploy_blocking, which was a free function beside them. head_branch, outcome_after_building_cancel, and outcome_after_cancel are probe_head_branch, rewrite_outcome_after_building_cancel, and map_outcome_after_cancel so each name starts with a verb. No tests removed. Signed-off-by: lorenzoberts --- src/kw/actor/mod.rs | 431 ++++++++++++++++++++++---------------------- 1 file changed, 220 insertions(+), 211 deletions(-) diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index fe4da77..d48405b 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -335,7 +335,7 @@ impl KwActor { }; let (cancel_tx, cancel_rx) = oneshot::channel(); - spawn(run_job(process, cancel_rx, self.job_event_tx.clone())); + spawn(Self::run_job(process, cancel_rx, self.job_event_tx.clone())); let phase = match kind { KwJobKind::Deploy => KwPhase::Deploying, @@ -409,7 +409,7 @@ impl KwActor { let output_dir = output_dir.map(Path::to_path_buf); let arch = arch.map(str::to_string); tokio::task::spawn_blocking(move || { - prepare_deploy_blocking( + Self::prepare_deploy_blocking( kind, &request, &*fs, @@ -442,9 +442,9 @@ impl KwActor { let branch = request.branch.clone(); let pre_job_branch = tokio::task::spawn_blocking(move || -> Result { - check_worktree_clean(&*shell, &tree_path)?; - let pre_job_branch = head_branch(&*shell, &tree_path); - switch_to_branch(&*shell, &tree_path, &branch)?; + KwGitService::check_worktree_clean(&*shell, &tree_path)?; + let pre_job_branch = KwGitService::probe_head_branch(&*shell, &tree_path); + KwGitService::switch_to_branch(&*shell, &tree_path, &branch)?; Ok(pre_job_branch) }) .await @@ -475,7 +475,7 @@ impl KwActor { let branch = branch.to_string(); let branch_for_task = branch.clone(); match tokio::task::spawn_blocking(move || { - switch_to_branch(&*shell, &tree_path, &branch_for_task) + KwGitService::switch_to_branch(&*shell, &tree_path, &branch_for_task) }) .await { @@ -555,7 +555,7 @@ impl KwActor { tracing::warn!("kw job finished with no job state recorded"); return; }; - let outcome = outcome_after_building_cancel(&job, outcome); + let outcome = Self::rewrite_outcome_after_building_cancel(&job, outcome); let job = match self.advance_build_then_deploy(job, &outcome) { ControlFlow::Break(()) => return, ControlFlow::Continue(job) => job, @@ -679,7 +679,7 @@ impl KwActor { match self.spawn_deploy_process(&job.tree_path, &deploy) { Ok((process, log_path)) => { let (cancel_tx, cancel_rx) = oneshot::channel(); - spawn(run_job(process, cancel_rx, self.job_event_tx.clone())); + spawn(Self::run_job(process, cancel_rx, self.job_event_tx.clone())); tracing::info!( kernel_tree_id = job.kernel_tree_id, branch = job.branch, @@ -846,7 +846,7 @@ impl KwActor { let kernel_tree_id = kernel_tree_id.to_string(); let tree = tree.clone(); tokio::task::spawn_blocking(move || { - let head = head_branch(&*shell, tree.path()); + let head = KwGitService::probe_head_branch(&*shell, tree.path()); readiness::ReadinessService::evaluate_readiness( &*fs, &*env, @@ -881,8 +881,8 @@ impl KwActor { // Same spawn_blocking rationale as the start path's checkout: the // switch rewrites the worktree. let result = tokio::task::spawn_blocking(move || -> Result<(), TreeGitError> { - check_worktree_clean(&*shell, &tree_path)?; - switch_to_branch(&*shell, &tree_path, &branch) + KwGitService::check_worktree_clean(&*shell, &tree_path)?; + KwGitService::switch_to_branch(&*shell, &tree_path, &branch) }) .await .map_err(|error| KwError::GitStateProbe(error.to_string())) @@ -905,228 +905,237 @@ impl KwActor { } } -/// Deploy gates that must not run on the actor task: remote.config, -/// history JSON, image globs, and boot-once files. Record match runs -/// before the boot-once confirm so a missing build refuses cheaper. -#[expect(clippy::too_many_arguments)] -fn prepare_deploy_blocking( - kind: KwJobKind, - request: &StartRequest, - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - history: &dyn KwHistoryStore, - tree_path: &Path, - output_dir: Option<&Path>, - arch: Option<&str>, -) -> Result { - let options = request.deploy.clone().unwrap_or(DeployOptions { - reboot: false, - force: true, - boot_once_acknowledged: false, - }); - let remote = remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path) - .map_err(KwStartError::RemoteUnresolved)?; - let mut kernelrelease = None; - if kind == KwJobKind::Deploy { - let (record, latest) = history.build_records(&request.kernel_tree_id, &request.branch)?; - let image = readiness::ReadinessService::find_newest_kernel_image( - fs, - output_dir.unwrap_or(tree_path), - arch, - ); - readiness::ReadinessService::check_deploy_alone( - record.as_ref(), - latest.as_ref(), - &request.tree, - &request.branch, - output_dir, - image.as_deref(), - ) - .map_err(KwStartError::DeployAloneRefused)?; - kernelrelease = record.and_then(|record| record.kernelrelease); - } - let boot_once = readiness::ReadinessService::probe_boot_once(fs, env, tree_path); - if matches!(boot_once, BootOnceState::On | BootOnceState::Unknown) - && !options.boot_once_acknowledged - { - return Err(KwStartError::BootOnceNotAcknowledged); +impl KwActor { + /// Deploy gates that must not run on the actor task: remote.config, + /// history JSON, image globs, and boot-once files. Record match runs + /// before the boot-once confirm so a missing build refuses cheaper. + #[expect(clippy::too_many_arguments)] + fn prepare_deploy_blocking( + kind: KwJobKind, + request: &StartRequest, + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + history: &dyn KwHistoryStore, + tree_path: &Path, + output_dir: Option<&Path>, + arch: Option<&str>, + ) -> Result { + let options = request.deploy.clone().unwrap_or(DeployOptions { + reboot: false, + force: true, + boot_once_acknowledged: false, + }); + let remote = remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path) + .map_err(KwStartError::RemoteUnresolved)?; + let mut kernelrelease = None; + if kind == KwJobKind::Deploy { + let (record, latest) = + history.build_records(&request.kernel_tree_id, &request.branch)?; + let image = readiness::ReadinessService::find_newest_kernel_image( + fs, + output_dir.unwrap_or(tree_path), + arch, + ); + readiness::ReadinessService::check_deploy_alone( + record.as_ref(), + latest.as_ref(), + &request.tree, + &request.branch, + output_dir, + image.as_deref(), + ) + .map_err(KwStartError::DeployAloneRefused)?; + kernelrelease = record.and_then(|record| record.kernelrelease); + } + let boot_once = readiness::ReadinessService::probe_boot_once(fs, env, tree_path); + if matches!(boot_once, BootOnceState::On | BootOnceState::Unknown) + && !options.boot_once_acknowledged + { + return Err(KwStartError::BootOnceNotAcknowledged); + } + Ok(JobDeploy { + remote, + options, + extra_args: request.extra_args.clone(), + kernelrelease, + }) } - Ok(JobDeploy { - remote, - options, - extra_args: request.extra_args.clone(), - kernelrelease, - }) -} -/// Fails unless the tree's git state verifies clean. Untracked files -/// don't count: kernel trees accumulate local scratch files, and only -/// tracked changes can corrupt the branch a job builds. A probe that -/// itself fails — git missing, not a repository — fails too. -fn check_worktree_clean(shell: &dyn ShellTrait, tree_path: &str) -> Result<(), TreeGitError> { - let cmd = ShellCommand::new("git").args([ - "-C", - tree_path, - "status", - "--porcelain", - "--untracked-files=no", - ]); - let output = shell - .execute(&cmd) - .map_err(|error| TreeGitError::Probe(error.to_string()))?; - if !output.success { - return Err(TreeGitError::Probe( - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )); - } - if !output.stdout.is_empty() { - return Err(TreeGitError::DirtyWorktree); + /// Owns the spawned process until it ends: waits on it, or — when the + /// cancel signal fires — runs the kill escalation and reaps it. The + /// `wait()` future is dropped before the cancel arm's body runs, releasing + /// the mutable borrow so `kill()` can be called. Reports the outcome back + /// to the actor over the internal event channel. + async fn run_job( + mut process: Box, + mut cancel_rx: oneshot::Receiver<()>, + events: mpsc::Sender, + ) { + let outcome = tokio::select! { + status = process.wait() => match status { + Ok(status) => JobOutcome::Exited(status), + Err(error) => JobOutcome::WaitFailed(error), + }, + // A dropped sender (actor shutting down) cancels the job too. + _ = &mut cancel_rx => Self::cancel_job(&mut *process).await, + }; + events.send(JobEvent::Finished(outcome)).await.ok(); } - Ok(()) -} -/// Switches the tree onto `branch`. A failure carries git's stderr, which -/// names the usual causes (no such branch, a rebase or merge in -/// progress). The `--` keeps a branch named like a flag from being parsed -/// as one. -fn switch_to_branch( - shell: &dyn ShellTrait, - tree_path: &str, - branch: &str, -) -> Result<(), TreeGitError> { - let cmd = ShellCommand::new("git").args(["-C", tree_path, "switch", "--", branch]); - let output = shell - .execute(&cmd) - .map_err(|error| TreeGitError::Switch(error.to_string()))?; - if !output.success { - return Err(TreeGitError::Switch( - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )); + /// Cancel escalation ladder: SIGTERM the group, wait a grace period, + /// SIGKILL, wait again, then give up. Giving up still reports Cancelled and + /// lets the actor clear its job state — a process group that ignores both + /// signals must not wedge the actor into refusing every later Start with + /// `JobAlreadyRunning` for the rest of the session. + async fn cancel_job(process: &mut dyn RunningProcess) -> JobOutcome { + if let Err(error) = process.kill() { + tracing::warn!(%error, "failed to SIGTERM kw job process group"); + } + match tokio::time::timeout(TERM_GRACE, process.wait()).await { + Ok(outcome) => Self::map_outcome_after_cancel(outcome), + Err(_) => { + tracing::warn!("kw job ignored SIGTERM; escalating to SIGKILL"); + if let Err(error) = process.force_kill() { + tracing::warn!(%error, "failed to SIGKILL kw job process group"); + } + match tokio::time::timeout(KILL_GRACE, process.wait()).await { + Ok(outcome) => Self::map_outcome_after_cancel(outcome), + Err(_) => { + tracing::warn!( + "kw job process group could not be reaped after SIGKILL; giving up" + ); + JobOutcome::Cancelled + } + } + } + } } - Ok(()) -} -/// The tree's current branch, probed via git. An unresolvable HEAD -/// (not a git repo, or a detached HEAD, for which `branch -/// --show-current` prints nothing) yields an empty string: no build -/// record can match it, so deploy-alone readiness refuses — the safe -/// direction for an unknown HEAD. -fn head_branch(shell: &dyn ShellTrait, tree_path: &str) -> String { - let cmd = ShellCommand::new("git").args(["-C", tree_path, "branch", "--show-current"]); - match shell.execute(&cmd) { - Ok(output) if output.success => String::from_utf8_lossy(&output.stdout).trim().to_string(), - Ok(output) => { - tracing::warn!( - tree = tree_path, - stderr = %String::from_utf8_lossy(&output.stderr), - "failed to probe the kernel tree's HEAD branch" - ); - String::new() - } - Err(error) => { - tracing::warn!(tree = tree_path, %error, "failed to probe the kernel tree's HEAD branch"); - String::new() + /// A cancel during Building of a BuildThenDeploy job must not chain into + /// deploy, even when the build process reports exit 0. Rewriting the + /// outcome to [`JobOutcome::Cancelled`] also skips the build record, matching + /// the "cancel in Building writes nothing" rule. + fn rewrite_outcome_after_building_cancel(job: &JobState, outcome: JobOutcome) -> JobOutcome { + if job.cancel_tx.is_none() + && job.kind == KwJobKind::BuildThenDeploy + && job.phase == KwPhase::Building + && matches!(&outcome, JobOutcome::Exited(exit) if exit.success()) + { + JobOutcome::Cancelled + } else { + outcome } } -} -/// Owns the spawned process until it ends: waits on it, or — when the -/// cancel signal fires — runs the kill escalation and reaps it. The -/// `wait()` future is dropped before the cancel arm's body runs, releasing -/// the mutable borrow so `kill()` can be called. Reports the outcome back -/// to the actor over the internal event channel. -async fn run_job( - mut process: Box, - mut cancel_rx: oneshot::Receiver<()>, - events: mpsc::Sender, -) { - let outcome = tokio::select! { - status = process.wait() => match status { - Ok(status) => JobOutcome::Exited(status), - Err(error) => JobOutcome::WaitFailed(error), - }, - // A dropped sender (actor shutting down) cancels the job too. - _ = &mut cancel_rx => cancel_job(&mut *process).await, - }; - events.send(JobEvent::Finished(outcome)).await.ok(); -} + /// Maps a reap result observed after a cancel request. A signal-terminated + /// process means our SIGTERM/SIGKILL landed — the job was really cancelled. + /// A plain exit means the process finished on its own before the signal: + /// report the real outcome, because a cancel must not mask a failure the + /// build history (and deploy-alone readiness) needs to see. + /// + /// Known, accepted edges: a process that *traps* our SIGTERM and exits 0 + /// counts as success for a standalone build (kw is bash, so this is + /// possible in principle). BuildThenDeploy does not chain that exit into + /// deploy — see [`Self::rewrite_outcome_after_building_cancel`]. An external signal + /// racing a cancel (e.g. the OOM killer) reads as Cancelled. Those cases + /// are indistinguishable from the honest ones without comparing who + /// signaled first, and both favor showing the user real output over + /// inventing failures. + fn map_outcome_after_cancel(result: Result) -> JobOutcome { + use std::os::unix::process::ExitStatusExt; -/// Cancel escalation ladder: SIGTERM the group, wait a grace period, -/// SIGKILL, wait again, then give up. Giving up still reports Cancelled and -/// lets the actor clear its job state — a process group that ignores both -/// signals must not wedge the actor into refusing every later Start with -/// `JobAlreadyRunning` for the rest of the session. -async fn cancel_job(process: &mut dyn RunningProcess) -> JobOutcome { - if let Err(error) = process.kill() { - tracing::warn!(%error, "failed to SIGTERM kw job process group"); - } - match tokio::time::timeout(TERM_GRACE, process.wait()).await { - Ok(outcome) => outcome_after_cancel(outcome), - Err(_) => { - tracing::warn!("kw job ignored SIGTERM; escalating to SIGKILL"); - if let Err(error) = process.force_kill() { - tracing::warn!(%error, "failed to SIGKILL kw job process group"); - } - match tokio::time::timeout(KILL_GRACE, process.wait()).await { - Ok(outcome) => outcome_after_cancel(outcome), - Err(_) => { - tracing::warn!( - "kw job process group could not be reaped after SIGKILL; giving up" - ); - JobOutcome::Cancelled + match result { + Ok(status) => { + if status.signal().is_some() { + return JobOutcome::Cancelled; + } + // kw is a bash wrapper: a process-group SIGTERM/SIGKILL often + // surfaces as a plain exit of 128+signum (143 / 137) rather than + // WIFSIGNALED. After a cancel request those are still cancels. + match status.code() { + Some(143 | 137) => JobOutcome::Cancelled, + _ => JobOutcome::Exited(status), } } + Err(error) => JobOutcome::WaitFailed(error), } } } -/// A cancel during Building of a BuildThenDeploy job must not chain into -/// deploy, even when the build process reports exit 0. Rewriting the -/// outcome to [`JobOutcome::Cancelled`] also skips the build record, matching -/// the "cancel in Building writes nothing" rule. -fn outcome_after_building_cancel(job: &JobState, outcome: JobOutcome) -> JobOutcome { - if job.cancel_tx.is_none() - && job.kind == KwJobKind::BuildThenDeploy - && job.phase == KwPhase::Building - && matches!(&outcome, JobOutcome::Exited(exit) if exit.success()) - { - JobOutcome::Cancelled - } else { - outcome +struct KwGitService; + +impl KwGitService { + /// Fails unless the tree's git state verifies clean. Untracked files + /// don't count: kernel trees accumulate local scratch files, and only + /// tracked changes can corrupt the branch a job builds. A probe that + /// itself fails — git missing, not a repository — fails too. + fn check_worktree_clean(shell: &dyn ShellTrait, tree_path: &str) -> Result<(), TreeGitError> { + let cmd = ShellCommand::new("git").args([ + "-C", + tree_path, + "status", + "--porcelain", + "--untracked-files=no", + ]); + let output = shell + .execute(&cmd) + .map_err(|error| TreeGitError::Probe(error.to_string()))?; + if !output.success { + return Err(TreeGitError::Probe( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + if !output.stdout.is_empty() { + return Err(TreeGitError::DirtyWorktree); + } + Ok(()) } -} -/// Maps a reap result observed after a cancel request. A signal-terminated -/// process means our SIGTERM/SIGKILL landed — the job was really cancelled. -/// A plain exit means the process finished on its own before the signal: -/// report the real outcome, because a cancel must not mask a failure the -/// build history (and deploy-alone readiness) needs to see. -/// -/// Known, accepted edges: a process that *traps* our SIGTERM and exits 0 -/// counts as success for a standalone build (kw is bash, so this is -/// possible in principle). BuildThenDeploy does not chain that exit into -/// deploy — see [`outcome_after_building_cancel`]. An external signal -/// racing a cancel (e.g. the OOM killer) reads as Cancelled. Those cases -/// are indistinguishable from the honest ones without comparing who -/// signaled first, and both favor showing the user real output over -/// inventing failures. -fn outcome_after_cancel(result: Result) -> JobOutcome { - use std::os::unix::process::ExitStatusExt; + /// Switches the tree onto `branch`. A failure carries git's stderr, which + /// names the usual causes (no such branch, a rebase or merge in + /// progress). The `--` keeps a branch named like a flag from being parsed + /// as one. + fn switch_to_branch( + shell: &dyn ShellTrait, + tree_path: &str, + branch: &str, + ) -> Result<(), TreeGitError> { + let cmd = ShellCommand::new("git").args(["-C", tree_path, "switch", "--", branch]); + let output = shell + .execute(&cmd) + .map_err(|error| TreeGitError::Switch(error.to_string()))?; + if !output.success { + return Err(TreeGitError::Switch( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + Ok(()) + } - match result { - Ok(status) => { - if status.signal().is_some() { - return JobOutcome::Cancelled; + /// The tree's current branch, probed via git. An unresolvable HEAD + /// (not a git repo, or a detached HEAD, for which `branch + /// --show-current` prints nothing) yields an empty string: no build + /// record can match it, so deploy-alone readiness refuses — the safe + /// direction for an unknown HEAD. + fn probe_head_branch(shell: &dyn ShellTrait, tree_path: &str) -> String { + let cmd = ShellCommand::new("git").args(["-C", tree_path, "branch", "--show-current"]); + match shell.execute(&cmd) { + Ok(output) if output.success => { + String::from_utf8_lossy(&output.stdout).trim().to_string() } - // kw is a bash wrapper: a process-group SIGTERM/SIGKILL often - // surfaces as a plain exit of 128+signum (143 / 137) rather than - // WIFSIGNALED. After a cancel request those are still cancels. - match status.code() { - Some(143 | 137) => JobOutcome::Cancelled, - _ => JobOutcome::Exited(status), + Ok(output) => { + tracing::warn!( + tree = tree_path, + stderr = %String::from_utf8_lossy(&output.stderr), + "failed to probe the kernel tree's HEAD branch" + ); + String::new() + } + Err(error) => { + tracing::warn!(tree = tree_path, %error, "failed to probe the kernel tree's HEAD branch"); + String::new() } } - Err(error) => JobOutcome::WaitFailed(error), } } From 7348f33118f556a4be2c3061bc2f98b07b96de8b Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:07:46 -0300 Subject: [PATCH 21/61] refactor(app): group apply and reviewed-reply actions into services ApplyPatchsetService and ReviewedReplyService hold the action functions. Apply keeps its own switch_to_branch: it takes a kernel tree, omits git's -- separator, and returns a formatted String, unlike KwGitService::switch_to_branch. Request builders that fed these actions are private App functions, renamed so each name starts with a verb. No tests removed. Signed-off-by: lorenzoberts --- src/app/actions/apply.rs | 479 ++++++++++++++++-------------- src/app/actions/mod.rs | 9 +- src/app/actions/reviewed_reply.rs | 178 ++++++----- src/app/mod.rs | 106 ++++--- 4 files changed, 413 insertions(+), 359 deletions(-) diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index 85c2d32..0bb2240 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -21,30 +21,33 @@ pub(crate) struct AppliedPatchset { pub applied_branch: String, } -pub(crate) fn apply_patchset( - request: &ApplyPatchsetRequest, - fs: &dyn FileSystemTrait, - shell: &dyn ShellTrait, - config: &ConfigSnapshot, -) -> Result { - let kernel_tree = validate_kernel_tree(fs, config)?; - check_git_state(fs, shell, kernel_tree)?; - - let original_branch = get_current_branch(shell, kernel_tree)?; - let target_branch = create_target_branch(shell, kernel_tree, config)?; - - let git_am_result = run_git_am(request, shell, kernel_tree, config); - - match git_am_result { - Ok(_) => { - let current_branch = if config.stay_on_applied_branch() { - target_branch.clone() - } else { - switch_to_branch(shell, kernel_tree, &original_branch)?; - original_branch - }; - - Ok(AppliedPatchset { +pub(crate) struct ApplyPatchsetService; + +impl ApplyPatchsetService { + pub(crate) fn apply_patchset( + request: &ApplyPatchsetRequest, + fs: &dyn FileSystemTrait, + shell: &dyn ShellTrait, + config: &ConfigSnapshot, + ) -> Result { + let kernel_tree = Self::validate_kernel_tree(fs, config)?; + Self::check_git_state(fs, shell, kernel_tree)?; + + let original_branch = Self::read_current_branch(shell, kernel_tree)?; + let target_branch = Self::create_target_branch(shell, kernel_tree, config)?; + + let git_am_result = Self::run_git_am(request, shell, kernel_tree, config); + + match git_am_result { + Ok(_) => { + let current_branch = if config.stay_on_applied_branch() { + target_branch.clone() + } else { + Self::switch_to_branch(shell, kernel_tree, &original_branch)?; + original_branch + }; + + Ok(AppliedPatchset { message: format!( " Patchset '{}' applied successfully!\n\n - Kernel Tree: '{}'\n\n - Base Branch: '{}'\n\n - Applied branch: '{}'\n\n - Current branch: '{}'", request.patch_title, @@ -55,229 +58,240 @@ pub(crate) fn apply_patchset( ), applied_branch: target_branch, }) - } - Err(e) => { - switch_to_branch(shell, kernel_tree, &original_branch)?; - let cleanup_note = match delete_branch(shell, kernel_tree, &target_branch) { - Ok(()) => String::new(), - Err(error) => format!("\n\n(could not delete '{target_branch}': {error})"), - }; - Err(format!( - " `git am` failed (back on branch '{original_branch}')\n{e}{cleanup_note}" - )) + } + Err(e) => { + Self::switch_to_branch(shell, kernel_tree, &original_branch)?; + let cleanup_note = match Self::delete_branch(shell, kernel_tree, &target_branch) { + Ok(()) => String::new(), + Err(error) => format!("\n\n(could not delete '{target_branch}': {error})"), + }; + Err(format!( + " `git am` failed (back on branch '{original_branch}')\n{e}{cleanup_note}" + )) + } } } -} -fn validate_kernel_tree<'a>( - fs: &dyn FileSystemTrait, - config: &'a ConfigSnapshot, -) -> Result<&'a KernelTree, String> { - let kernel_tree_id = if let Some(target) = config.target_kernel_tree() { - target - } else { - return Err("target kernel tree unset".to_string()); - }; - - let kernel_tree = if let Some(tree) = config.get_kernel_tree(kernel_tree_id) { - tree - } else { - return Err(format!("invalid target kernel tree '{kernel_tree_id}'")); - }; + fn validate_kernel_tree<'a>( + fs: &dyn FileSystemTrait, + config: &'a ConfigSnapshot, + ) -> Result<&'a KernelTree, String> { + let kernel_tree_id = if let Some(target) = config.target_kernel_tree() { + target + } else { + return Err("target kernel tree unset".to_string()); + }; + + let kernel_tree = if let Some(tree) = config.get_kernel_tree(kernel_tree_id) { + tree + } else { + return Err(format!("invalid target kernel tree '{kernel_tree_id}'")); + }; + + let kernel_tree_path = Path::new(kernel_tree.path()); + if !fs.is_dir(kernel_tree_path) { + return Err(format!("{} isn't a directory", kernel_tree.path())); + } else if !fs.is_dir(&kernel_tree_path.join(".git")) { + return Err(format!("{} isn't a git repository", kernel_tree.path())); + } - let kernel_tree_path = Path::new(kernel_tree.path()); - if !fs.is_dir(kernel_tree_path) { - return Err(format!("{} isn't a directory", kernel_tree.path())); - } else if !fs.is_dir(&kernel_tree_path.join(".git")) { - return Err(format!("{} isn't a git repository", kernel_tree.path())); + Ok(kernel_tree) } - Ok(kernel_tree) -} + fn check_git_state( + fs: &dyn FileSystemTrait, + shell: &dyn ShellTrait, + kernel_tree: &KernelTree, + ) -> Result<(), String> { + let kernel_tree_path = Path::new(kernel_tree.path()); -fn check_git_state( - fs: &dyn FileSystemTrait, - shell: &dyn ShellTrait, - kernel_tree: &KernelTree, -) -> Result<(), String> { - let kernel_tree_path = Path::new(kernel_tree.path()); - - if fs.is_dir(&kernel_tree_path.join(".git/rebase-merge")) { - return Err("rebase in progress. \nrun `git rebase --abort` before continuing".to_string()); - } else if fs.is_file(&kernel_tree_path.join(".git/MERGE_HEAD")) { - return Err("merge in progress. \nrun `git merge --abort` before continuing".to_string()); - } else if fs.is_file(&kernel_tree_path.join(".git/BISECT_LOG")) { - return Err("bisect in progress. \nrun `git bisect reset` before continuing".to_string()); - } else if fs.is_dir(&kernel_tree_path.join(".git/rebase-apply")) { - return Err( - "`git am` already in progress. \nrun `git am --abort` before continuing".to_string(), - ); - } + if fs.is_dir(&kernel_tree_path.join(".git/rebase-merge")) { + return Err( + "rebase in progress. \nrun `git rebase --abort` before continuing".to_string(), + ); + } else if fs.is_file(&kernel_tree_path.join(".git/MERGE_HEAD")) { + return Err( + "merge in progress. \nrun `git merge --abort` before continuing".to_string(), + ); + } else if fs.is_file(&kernel_tree_path.join(".git/BISECT_LOG")) { + return Err( + "bisect in progress. \nrun `git bisect reset` before continuing".to_string(), + ); + } else if fs.is_dir(&kernel_tree_path.join(".git/rebase-apply")) { + return Err( + "`git am` already in progress. \nrun `git am --abort` before continuing" + .to_string(), + ); + } - let status_out = shell - .execute( - &ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["status", "--porcelain"]), - ) - .map_err(|e| format!("failed to check git status {e}"))?; - - let status_output = String::from_utf8_lossy(&status_out.stdout); - if !status_output.is_empty() { - return Err(format!( - "there are staged and/or unstaged changes\n{status_output}" - )); - } + let status_out = shell + .execute( + &ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["status", "--porcelain"]), + ) + .map_err(|e| format!("failed to check git status {e}"))?; + + let status_output = String::from_utf8_lossy(&status_out.stdout); + if !status_output.is_empty() { + return Err(format!( + "there are staged and/or unstaged changes\n{status_output}" + )); + } + + let show_ref_out = shell + .execute( + &ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["show-ref", "--verify", "--quiet"]) + .arg(format!("refs/heads/{}", kernel_tree.branch())), + ) + .map_err(|e| format!("failed to verify branch: {e}"))?; + + if !show_ref_out.success { + return Err(format!( + "invalid branch '{}' for '{}'", + kernel_tree.branch(), + kernel_tree.path() + )); + } - let show_ref_out = shell - .execute( - &ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["show-ref", "--verify", "--quiet"]) - .arg(format!("refs/heads/{}", kernel_tree.branch())), - ) - .map_err(|e| format!("failed to verify branch: {e}"))?; - - if !show_ref_out.success { - return Err(format!( - "invalid branch '{}' for '{}'", - kernel_tree.branch(), - kernel_tree.path() - )); + Ok(()) } - Ok(()) -} + fn read_current_branch( + shell: &dyn ShellTrait, + kernel_tree: &KernelTree, + ) -> Result { + let out = shell + .execute( + &ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["rev-parse", "--abbrev-ref", "HEAD"]), + ) + .map_err(|e| format!("failed to get current branch: {e}"))?; + + let mut branch = String::from_utf8_lossy(&out.stdout).to_string(); + branch.pop(); + Ok(branch) + } -fn get_current_branch(shell: &dyn ShellTrait, kernel_tree: &KernelTree) -> Result { - let out = shell - .execute( - &ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["rev-parse", "--abbrev-ref", "HEAD"]), - ) - .map_err(|e| format!("failed to get current branch: {e}"))?; - - let mut branch = String::from_utf8_lossy(&out.stdout).to_string(); - branch.pop(); - Ok(branch) -} + fn switch_to_branch( + shell: &dyn ShellTrait, + kernel_tree: &KernelTree, + branch: &str, + ) -> Result<(), String> { + let out = shell + .execute( + &ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["switch", branch]), + ) + .map_err(|e| format!("failed to switch branch: {e}"))?; + + if !out.success { + return Err(format!( + "failed to switch to branch '{}': {}", + branch, + String::from_utf8_lossy(&out.stderr) + )); + } -fn switch_to_branch( - shell: &dyn ShellTrait, - kernel_tree: &KernelTree, - branch: &str, -) -> Result<(), String> { - let out = shell - .execute( - &ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["switch", branch]), - ) - .map_err(|e| format!("failed to switch branch: {e}"))?; - - if !out.success { - return Err(format!( - "failed to switch to branch '{}': {}", - branch, - String::from_utf8_lossy(&out.stderr) - )); + Ok(()) } - Ok(()) -} + fn create_target_branch( + shell: &dyn ShellTrait, + kernel_tree: &KernelTree, + config: &ConfigSnapshot, + ) -> Result { + Self::switch_to_branch(shell, kernel_tree, kernel_tree.branch())?; + + let target_branch_name = format!( + "{}{}", + config.git_am_branch_prefix(), + Utc::now().format("%Y-%m-%d-%H-%M-%S") + ); + + let out = shell + .execute( + &ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["checkout", "-b", &target_branch_name]), + ) + .map_err(|e| format!("failed to create target branch: {e}"))?; + + if !out.success { + return Err(format!( + "failed to create branch '{}': {}", + target_branch_name, + String::from_utf8_lossy(&out.stderr) + )); + } -fn create_target_branch( - shell: &dyn ShellTrait, - kernel_tree: &KernelTree, - config: &ConfigSnapshot, -) -> Result { - switch_to_branch(shell, kernel_tree, kernel_tree.branch())?; - - let target_branch_name = format!( - "{}{}", - config.git_am_branch_prefix(), - Utc::now().format("%Y-%m-%d-%H-%M-%S") - ); - - let out = shell - .execute( - &ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["checkout", "-b", &target_branch_name]), - ) - .map_err(|e| format!("failed to create target branch: {e}"))?; - - if !out.success { - return Err(format!( - "failed to create branch '{}': {}", - target_branch_name, - String::from_utf8_lossy(&out.stderr) - )); + Ok(target_branch_name) } - Ok(target_branch_name) -} + /// Deletes a branch `create_target_branch` made for an apply that failed. + /// `-D` because `git am --abort` leaves it at the base commit, which is not + /// necessarily merged into the branch HEAD switched back to. + fn delete_branch( + shell: &dyn ShellTrait, + kernel_tree: &KernelTree, + branch: &str, + ) -> Result<(), String> { + let out = shell + .execute( + &ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["branch", "-D", branch]), + ) + .map_err(|e| e.to_string())?; + + if !out.success { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } -/// Deletes a branch `create_target_branch` made for an apply that failed. -/// `-D` because `git am --abort` leaves it at the base commit, which is not -/// necessarily merged into the branch HEAD switched back to. -fn delete_branch( - shell: &dyn ShellTrait, - kernel_tree: &KernelTree, - branch: &str, -) -> Result<(), String> { - let out = shell - .execute( - &ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["branch", "-D", branch]), - ) - .map_err(|e| e.to_string())?; - - if !out.success { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + Ok(()) } - Ok(()) -} + fn run_git_am( + request: &ApplyPatchsetRequest, + shell: &dyn ShellTrait, + kernel_tree: &KernelTree, + config: &ConfigSnapshot, + ) -> Result<(), String> { + let mut git_am_cmd = ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["am", &request.patchset_path]); + for opt in config.git_am_options().split_whitespace() { + git_am_cmd = git_am_cmd.arg(opt); + } -fn run_git_am( - request: &ApplyPatchsetRequest, - shell: &dyn ShellTrait, - kernel_tree: &KernelTree, - config: &ConfigSnapshot, -) -> Result<(), String> { - let mut git_am_cmd = ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["am", &request.patchset_path]); - for opt in config.git_am_options().split_whitespace() { - git_am_cmd = git_am_cmd.arg(opt); - } + let out = shell + .execute(&git_am_cmd) + .map_err(|e| format!("failed to execute git-am: {e}"))?; - let out = shell - .execute(&git_am_cmd) - .map_err(|e| format!("failed to execute git-am: {e}"))?; + if !out.success { + let _ = shell.execute( + &ShellCommand::new("git") + .arg("-C") + .arg(kernel_tree.path()) + .args(["am", "--abort"]), + ); - if !out.success { - let _ = shell.execute( - &ShellCommand::new("git") - .arg("-C") - .arg(kernel_tree.path()) - .args(["am", "--abort"]), - ); + return Err(String::from_utf8_lossy(&out.stderr).to_string()); + } - return Err(String::from_utf8_lossy(&out.stderr).to_string()); + Ok(()) } - - Ok(()) } #[cfg(test)] @@ -408,7 +422,8 @@ mod tests { output("", "", true), ]); - let applied = apply_patchset(&request(), &fs, &shell, &config()).unwrap(); + let applied = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap(); assert!(applied.applied_branch.starts_with("patchset-")); assert!(applied @@ -483,7 +498,9 @@ mod tests { output("", "", true), ]); - let applied = apply_patchset(&request(), &fs, &shell, &config_stay_disabled()).unwrap(); + let applied = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config_stay_disabled()) + .unwrap(); assert!(applied.message.contains("Current branch: 'feature'")); let calls = calls.lock().unwrap(); @@ -499,7 +516,8 @@ mod tests { let fs = clean_fs(); let (shell, calls) = shell_with_outputs(vec![output(" M file.rs\n", "", true)]); - let result = apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); + let result = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); assert!(result.contains("there are staged and/or unstaged changes")); assert_eq!(1, calls.lock().unwrap().len()); @@ -513,7 +531,9 @@ mod tests { let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); - let result = apply_patchset(&request(), &fs, &shell, &config_without_target()).unwrap_err(); + let result = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config_without_target()) + .unwrap_err(); assert_eq!("target kernel tree unset", result); } @@ -526,7 +546,8 @@ mod tests { output("", "missing branch", false), ]); - let result = apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); + let result = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); assert!(result.contains("invalid branch 'main'")); assert_eq!(2, calls.lock().unwrap().len()); @@ -548,7 +569,8 @@ mod tests { output("", "", true), ]); - let result = apply_patchset(&request(), &fs, &shell, &config).unwrap_err(); + let result = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config).unwrap_err(); assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed")); assert!(!result.contains("could not delete")); @@ -584,7 +606,8 @@ mod tests { output("", "error: branch is locked\n", false), ]); - let result = apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); + let result = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed")); assert!(result.contains("could not delete 'patchset-")); diff --git a/src/app/actions/mod.rs b/src/app/actions/mod.rs index ee35f68..a0fce97 100644 --- a/src/app/actions/mod.rs +++ b/src/app/actions/mod.rs @@ -36,13 +36,18 @@ impl<'a> PatchsetActionService<'a> { request: &ApplyPatchsetRequest, config: &ConfigSnapshot, ) -> Result { - apply::apply_patchset(request, self.fs, self.shell, config) + apply::ApplyPatchsetService::apply_patchset(request, self.fs, self.shell, config) } pub(crate) async fn execute_reviewed_reply( &self, request: ReviewedReplyRequest, ) -> Result { - reviewed_reply::execute_reviewed_reply(request, self.lore_api, self.shell).await + reviewed_reply::ReviewedReplyService::execute_reviewed_reply( + request, + self.lore_api, + self.shell, + ) + .await } } diff --git a/src/app/actions/reviewed_reply.rs b/src/app/actions/reviewed_reply.rs index 25a7ba7..9f01218 100644 --- a/src/app/actions/reviewed_reply.rs +++ b/src/app/actions/reviewed_reply.rs @@ -30,88 +30,92 @@ impl ReviewedReplyResult { } } -pub(crate) async fn execute_reviewed_reply( - request: ReviewedReplyRequest, - lore_api: &LoreApiHandle, - shell: &dyn ShellTrait, -) -> Result { - if !request.patches_to_reply.contains(&true) { - return Ok(ReviewedReplyResult::NoAction { - successful_indexes: request.successful_indexes, - }); - } +pub(crate) struct ReviewedReplyService; + +impl ReviewedReplyService { + pub(crate) async fn execute_reviewed_reply( + request: ReviewedReplyRequest, + lore_api: &LoreApiHandle, + shell: &dyn ShellTrait, + ) -> Result { + if !request.patches_to_reply.contains(&true) { + return Ok(ReviewedReplyResult::NoAction { + successful_indexes: request.successful_indexes, + }); + } - let (git_user_name, git_user_email) = lore_api - .get_git_signature(String::new()) - .await - .map_err(|e| eyre!("{e:#?}"))?; - - let mut successful_indexes = request.successful_indexes; - let Some(git_signature) = git_signature(git_user_name, git_user_email) else { - println!("`git config user.name` or `git config user.email` not set\nAborting..."); - return Ok(ReviewedReplyResult::MissingGitIdentity { successful_indexes }); - }; - - let mktemp_cmd = ShellCommand::new("mktemp").arg("--directory"); - let tmp_out = shell - .execute(&mktemp_cmd) - .map_err(|e| eyre!("failed to create temp directory: {}", e))?; - let tmp_dir_str = str::from_utf8(&tmp_out.stdout) - .map_err(|e| eyre!("invalid utf-8 in temp dir path: {}", e))? - .trim() - .to_string(); - let tmp_dir = PathBuf::from(tmp_dir_str); - - let git_reply_commands = lore_api - .prepare_reply_commands( - tmp_dir, - "all".to_string(), - request.raw_patches, - request.patches_to_reply.clone(), - git_signature, - request.git_send_email_options, - ) - .await - .map_err(|e| eyre!("{e:#?}"))?; - - record_successful_reply_indexes( - shell, - &mut successful_indexes, - &request.patches_to_reply, - git_reply_commands, - ); - - Ok(ReviewedReplyResult::Completed { successful_indexes }) -} + let (git_user_name, git_user_email) = lore_api + .get_git_signature(String::new()) + .await + .map_err(|e| eyre!("{e:#?}"))?; + + let mut successful_indexes = request.successful_indexes; + let Some(git_signature) = Self::format_git_signature(git_user_name, git_user_email) else { + println!("`git config user.name` or `git config user.email` not set\nAborting..."); + return Ok(ReviewedReplyResult::MissingGitIdentity { successful_indexes }); + }; -fn record_successful_reply_indexes( - shell: &dyn ShellTrait, - successful_indexes: &mut HashSet, - patches_to_reply: &[bool], - git_reply_commands: Vec, -) { - let reply_indexes: Vec = selected_reply_indexes(patches_to_reply); - for (i, command) in git_reply_commands.into_iter().enumerate() { - let success = shell.spawn_interactive(&command).unwrap_or(false); - if success { - successful_indexes.insert(reply_indexes[i]); + let mktemp_cmd = ShellCommand::new("mktemp").arg("--directory"); + let tmp_out = shell + .execute(&mktemp_cmd) + .map_err(|e| eyre!("failed to create temp directory: {}", e))?; + let tmp_dir_str = str::from_utf8(&tmp_out.stdout) + .map_err(|e| eyre!("invalid utf-8 in temp dir path: {}", e))? + .trim() + .to_string(); + let tmp_dir = PathBuf::from(tmp_dir_str); + + let git_reply_commands = lore_api + .prepare_reply_commands( + tmp_dir, + "all".to_string(), + request.raw_patches, + request.patches_to_reply.clone(), + git_signature, + request.git_send_email_options, + ) + .await + .map_err(|e| eyre!("{e:#?}"))?; + + Self::record_successful_reply_indexes( + shell, + &mut successful_indexes, + &request.patches_to_reply, + git_reply_commands, + ); + + Ok(ReviewedReplyResult::Completed { successful_indexes }) + } + + fn record_successful_reply_indexes( + shell: &dyn ShellTrait, + successful_indexes: &mut HashSet, + patches_to_reply: &[bool], + git_reply_commands: Vec, + ) { + let reply_indexes: Vec = Self::collect_selected_reply_indexes(patches_to_reply); + for (i, command) in git_reply_commands.into_iter().enumerate() { + let success = shell.spawn_interactive(&command).unwrap_or(false); + if success { + successful_indexes.insert(reply_indexes[i]); + } } } -} -fn selected_reply_indexes(patches_to_reply: &[bool]) -> Vec { - patches_to_reply - .iter() - .enumerate() - .filter_map(|(i, &val)| if val { Some(i) } else { None }) - .collect() -} + fn collect_selected_reply_indexes(patches_to_reply: &[bool]) -> Vec { + patches_to_reply + .iter() + .enumerate() + .filter_map(|(i, &val)| if val { Some(i) } else { None }) + .collect() + } -fn git_signature(git_user_name: String, git_user_email: String) -> Option { - if git_user_name.is_empty() || git_user_email.is_empty() { - None - } else { - Some(format!("{git_user_name} <{git_user_email}>")) + fn format_git_signature(git_user_name: String, git_user_email: String) -> Option { + if git_user_name.is_empty() || git_user_email.is_empty() { + None + } else { + Some(format!("{git_user_name} <{git_user_email}>")) + } } } @@ -129,7 +133,8 @@ mod tests { #[test] fn selected_reply_indexes_preserves_original_patch_indexes() { - let indexes = selected_reply_indexes(&[false, true, false, true]); + let indexes = + ReviewedReplyService::collect_selected_reply_indexes(&[false, true, false, true]); assert_eq!(vec![1, 3], indexes); } @@ -138,13 +143,22 @@ mod tests { fn git_signature_requires_name_and_email() { assert_eq!( Some("User ".to_string()), - git_signature("User".to_string(), "user@example.com".to_string()) + ReviewedReplyService::format_git_signature( + "User".to_string(), + "user@example.com".to_string() + ) + ); + assert_eq!( + None, + ReviewedReplyService::format_git_signature( + String::new(), + "user@example.com".to_string() + ) ); assert_eq!( None, - git_signature(String::new(), "user@example.com".to_string()) + ReviewedReplyService::format_git_signature("User".to_string(), String::new()) ); - assert_eq!(None, git_signature("User".to_string(), String::new())); } #[test] @@ -156,7 +170,7 @@ mod tests { .returning(|cmd| Ok(cmd.args.last().is_some_and(|arg| arg == "first"))); let mut successful_indexes = HashSet::from([0]); - record_successful_reply_indexes( + ReviewedReplyService::record_successful_reply_indexes( &shell, &mut successful_indexes, &[false, true, false, true], @@ -175,7 +189,7 @@ mod tests { .returning(|_| Err(ShellError::IoError(io::Error::other("failed")))); let mut successful_indexes = HashSet::new(); - record_successful_reply_indexes( + ReviewedReplyService::record_successful_reply_indexes( &shell, &mut successful_indexes, &[true], diff --git a/src/app/mod.rs b/src/app/mod.rs index 1f667ad..db43fde 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -375,7 +375,7 @@ impl App { .details .as_ref() .expect("invariant: details must be loaded before executing reviewed reply"); - if patchset_action_selected(details, &PatchsetAction::ReplyWithReviewedBy) { + if Self::is_patchset_action_selected(details, &PatchsetAction::ReplyWithReviewedBy) { let message_id = details.representative_patch.message_id().href.clone(); debug!(msg_id = message_id, "executing reviewed-by reply"); let successful_indexes = self @@ -384,7 +384,7 @@ impl App { .reviewed_patchsets .remove(&message_id) .unwrap_or_default(); - let request = reviewed_reply_request( + let request = Self::build_reviewed_reply_request( details, successful_indexes, self.state.config.git_send_email_options().to_string(), @@ -429,7 +429,7 @@ impl App { .as_ref() .expect("invariant: details must be loaded before executing apply patchset"); - if patchset_action_selected(details, &PatchsetAction::Apply) { + if Self::is_patchset_action_selected(details, &PatchsetAction::Apply) { debug!("applying patchset via git-am"); // A running kw job owns the tree; applying would rewrite the // branch it is building. AppActor serializes this with Start, @@ -469,7 +469,7 @@ impl App { /// Runs the git-am apply and maps the outcome to the result popup, /// recording the apply in the kw history on success. async fn apply_patchset_popup(&self, details: &PatchsetDetailsState) -> popup::AppPopup { - let request = apply_patchset_request(details); + let request = Self::build_apply_patchset_request(details); let action_service = PatchsetActionService::new( &*self.services.fs, &*self.services.shell, @@ -477,7 +477,11 @@ impl App { ); match action_service.apply_patchset(&request, &self.state.config) { Ok(applied) => { - let popup_body = match kw_apply_record(details, &self.state.config, &applied) { + let popup_body = match Self::build_kw_apply_record( + details, + &self.state.config, + &applied, + ) { // Defensive: the apply itself resolved this tree from // the same snapshot, so this is unreachable unless the // config changed mid-apply. @@ -559,48 +563,50 @@ impl App { pub fn present(&self) -> AppViewModel { view_model::project_state(&self.state) } -} - -fn patchset_action_selected(details: &PatchsetDetailsState, action: &PatchsetAction) -> bool { - matches!(details.patchset_actions.get(action), Some(true)) -} + fn is_patchset_action_selected( + details: &PatchsetDetailsState, + action: &PatchsetAction, + ) -> bool { + matches!(details.patchset_actions.get(action), Some(true)) + } -fn reviewed_reply_request( - details: &PatchsetDetailsState, - successful_indexes: std::collections::HashSet, - git_send_email_options: String, -) -> ReviewedReplyRequest { - ReviewedReplyRequest { - raw_patches: details.raw_patches.clone(), - patches_to_reply: details.patches_to_reply.clone(), - successful_indexes, - git_send_email_options, + fn build_reviewed_reply_request( + details: &PatchsetDetailsState, + successful_indexes: std::collections::HashSet, + git_send_email_options: String, + ) -> ReviewedReplyRequest { + ReviewedReplyRequest { + raw_patches: details.raw_patches.clone(), + patches_to_reply: details.patches_to_reply.clone(), + successful_indexes, + git_send_email_options, + } } -} -fn apply_patchset_request(details: &PatchsetDetailsState) -> ApplyPatchsetRequest { - ApplyPatchsetRequest { - patch_title: details.representative_patch.title().clone(), - patchset_path: details.patchset_path.clone(), + fn build_apply_patchset_request(details: &PatchsetDetailsState) -> ApplyPatchsetRequest { + ApplyPatchsetRequest { + patch_title: details.representative_patch.title().clone(), + patchset_path: details.patchset_path.clone(), + } } -} -fn kw_apply_record( - details: &PatchsetDetailsState, - config: &ConfigSnapshot, - applied: &AppliedPatchset, -) -> Option { - let kernel_tree_id = config.target_kernel_tree().as_ref()?; - let kernel_tree = config.get_kernel_tree(kernel_tree_id)?; - - Some(KwApplyRecord { - message_id: details.representative_patch.message_id().href.clone(), - kernel_tree_id: kernel_tree_id.clone(), - tree_path: kernel_tree.path().clone(), - applied_branch: applied.applied_branch.clone(), - base_branch: kernel_tree.branch().clone(), - applied_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - }) + fn build_kw_apply_record( + details: &PatchsetDetailsState, + config: &ConfigSnapshot, + applied: &AppliedPatchset, + ) -> Option { + let kernel_tree_id = config.target_kernel_tree().as_ref()?; + let kernel_tree = config.get_kernel_tree(kernel_tree_id)?; + + Some(KwApplyRecord { + message_id: details.representative_patch.message_id().href.clone(), + kernel_tree_id: kernel_tree_id.clone(), + tree_path: kernel_tree.path().clone(), + applied_branch: applied.applied_branch.clone(), + base_branch: kernel_tree.branch().clone(), + applied_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + }) + } } #[cfg(test)] @@ -658,8 +664,11 @@ mod tests { fn patchset_action_selected_reads_action_map() { let mut details = details_state(); - assert!(patchset_action_selected(&details, &PatchsetAction::Apply)); - assert!(patchset_action_selected( + assert!(App::is_patchset_action_selected( + &details, + &PatchsetAction::Apply + )); + assert!(App::is_patchset_action_selected( &details, &PatchsetAction::ReplyWithReviewedBy )); @@ -668,13 +677,16 @@ mod tests { .patchset_actions .insert(PatchsetAction::Apply, false); - assert!(!patchset_action_selected(&details, &PatchsetAction::Apply)); + assert!(!App::is_patchset_action_selected( + &details, + &PatchsetAction::Apply + )); } #[test] fn reviewed_reply_request_copies_reply_inputs() { let details = details_state(); - let request = reviewed_reply_request( + let request = App::build_reviewed_reply_request( &details, HashSet::from([4usize]), "--dry-run --suppress-cc=all".to_string(), @@ -692,7 +704,7 @@ mod tests { #[test] fn apply_patchset_request_copies_apply_inputs() { let details = details_state(); - let request = apply_patchset_request(&details); + let request = App::build_apply_patchset_request(&details); assert_eq!("[PATCH 1/1] test patch", request.patch_title); assert_eq!("/tmp/patchset.mbx", request.patchset_path); From e127b540294e3d36eb98848d157279c50f3dbdf2 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:12:28 -0300 Subject: [PATCH 22/61] refactor(app): turn screen flows into App methods Screen handlers now run on App. Each flow's help popup is an associated build_*_help_popup. Helpers that did not start with a verb are map_tail_read, is_snapshot_job_terminal, is_job_running, is_job_busy, and count_preview_scroll_lines. No tests removed. Signed-off-by: lorenzoberts --- src/app/actor.rs | 44 +- src/app/flows/bookmarked.rs | 84 +- src/app/flows/details_actions.rs | 214 ++--- src/app/flows/edit_config.rs | 144 +-- src/app/flows/kw_ops.rs | 927 ++++++++++--------- src/app/flows/latest.rs | 158 ++-- src/app/flows/mail_list.rs | 166 ++-- src/app/flows/open_patchset.rs | 48 +- src/app/integration_tests/flow_errors.rs | 57 +- src/app/integration_tests/flow_navigation.rs | 67 +- src/app/integration_tests/kw_ops.rs | 107 +-- 11 files changed, 991 insertions(+), 1025 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 377222f..4d8bc42 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -16,18 +16,6 @@ use tokio::{spawn, sync::mpsc, sync::watch, time::MissedTickBehavior}; use crate::{ app::{ - flows::{ - bookmarked::handle_bookmarked_patchsets, - details_actions::handle_patchset_details, - edit_config::handle_edit_config, - kw_ops::{ - apply_kw_snapshot, apply_kw_snapshot_refreshing_readiness, clear_pending_deploy, - fallback_kw_status, handle_kw_ops, poll_kw_status, refresh_kw_ops_log_tail, - resume_pending_deploy, - }, - latest::handle_latest_patchsets, - mail_list::handle_mailing_list_selection, - }, handle::AppHandle, loading::{terminal_error, TerminalLoadingIndicator}, popup::{AppPopup, ConfirmAction}, @@ -145,10 +133,10 @@ impl AppActor { watch_event = kw_status_changed(&mut kw_status_rx) => { match watch_event { KwWatchEvent::Updated(snapshot) => { - apply_kw_snapshot_refreshing_readiness(&mut self.app, snapshot).await; + self.app.apply_kw_snapshot_refreshing_readiness(snapshot).await; if self.app.state.navigation.current_screen == CurrentScreen::KwOps { - refresh_kw_ops_log_tail(&mut self.app).await; + self.app.refresh_kw_ops_log_tail().await; } } KwWatchEvent::Closed => { @@ -156,7 +144,7 @@ impl AppActor { "kw status watch closed; falling back to keyboard-only redraws" ); kw_status_rx = None; - fallback_kw_status(&mut self.app).await; + self.app.fallback_kw_status().await; } } redraw = true; @@ -164,10 +152,10 @@ impl AppActor { _ = log_interval.tick(), if tail_while_running || poll_status => { let mut changed = false; if poll_status { - changed |= poll_kw_status(&mut self.app).await; + changed |= self.app.poll_kw_status().await; } if tail_while_running { - changed |= refresh_kw_ops_log_tail(&mut self.app).await; + changed |= self.app.refresh_kw_ops_log_tail().await; } redraw = changed; } @@ -187,7 +175,7 @@ impl AppActor { let kw = self.app.services.kw.clone()?; match kw.watch_status().await { Ok(mut rx) => { - apply_kw_snapshot(&mut self.app, rx.borrow_and_update().clone()); + self.app.apply_kw_snapshot(rx.borrow_and_update().clone()); Some(rx) } Err(error) => { @@ -195,7 +183,7 @@ impl AppActor { %error, "failed to subscribe to kw status; keyboard-only redraws" ); - fallback_kw_status(&mut self.app).await; + self.app.fallback_kw_status().await; None } } @@ -270,25 +258,25 @@ async fn on_input( tracing::debug!(screen = ?app.state.navigation.current_screen, "dispatching input to screen handler"); match app.state.navigation.current_screen { CurrentScreen::MailingListSelection => { - match handle_mailing_list_selection(app, input, loading).await? { + match app.handle_mailing_list_selection(input, loading).await? { ControlFlow::Continue(()) => {} ControlFlow::Break(()) => return Ok(ControlFlow::Break(())), } } CurrentScreen::BookmarkedPatchsets => { - handle_bookmarked_patchsets(app, input, loading).await?; + app.handle_bookmarked_patchsets(input, loading).await?; } CurrentScreen::PatchsetDetails => { - handle_patchset_details(app, input, terminal_handle).await?; + app.handle_patchset_details(input, terminal_handle).await?; } CurrentScreen::EditConfig => { - handle_edit_config(app, input).await?; + app.handle_edit_config(input).await?; } CurrentScreen::LatestPatchsets => { - handle_latest_patchsets(app, input, loading).await?; + app.handle_latest_patchsets(input, loading).await?; } CurrentScreen::KwOps => { - handle_kw_ops(app, input).await?; + app.handle_kw_ops(input).await?; } } } @@ -304,7 +292,7 @@ fn is_boot_once_confirm(popup: &AppPopup) -> bool { fn dismiss_open_popup(app: &mut App) { if app.state.popup.as_ref().is_some_and(is_boot_once_confirm) { - clear_pending_deploy(app); + app.clear_pending_deploy(); } app.state.popup = None; } @@ -315,11 +303,11 @@ async fn apply_confirm_action(app: &mut App, action: ConfirmAction) -> Result Ok(cancel_kw_and_quit(app).await), ConfirmAction::Wait => Ok(ControlFlow::Continue(())), ConfirmAction::ProceedWithBootOnce => { - resume_pending_deploy(app).await?; + app.resume_pending_deploy().await?; Ok(ControlFlow::Continue(())) } ConfirmAction::BackOut => { - clear_pending_deploy(app); + app.clear_pending_deploy(); Ok(ControlFlow::Continue(())) } } diff --git a/src/app/flows/bookmarked.rs b/src/app/flows/bookmarked.rs index e804ae5..09a5b72 100644 --- a/src/app/flows/bookmarked.rs +++ b/src/app/flows/bookmarked.rs @@ -7,51 +7,50 @@ use crate::{ input::event::InputEvent, }; -use super::open_patchset::apply_open_patchset_result; - -pub async fn handle_bookmarked_patchsets( - app: &mut App, - input: InputEvent, - loading: &mut dyn LoadingIndicator, -) -> Result<()> { - match input { - InputEvent::OpenHelp => { - let popup = generate_help_popup(); - app.state.popup = Some(popup); - } - InputEvent::Back => { - app.state.user_state.bookmarked_patchsets.patchset_index = 0; - app.set_current_screen(CurrentScreen::MailingListSelection); - } - InputEvent::NavigateDown => { - app.state - .user_state - .bookmarked_patchsets - .select_below_patchset(); - } - InputEvent::NavigateUp => { - app.state - .user_state - .bookmarked_patchsets - .select_above_patchset(); +impl App { + pub async fn handle_bookmarked_patchsets( + &mut self, + input: InputEvent, + loading: &mut dyn LoadingIndicator, + ) -> Result<()> { + match input { + InputEvent::OpenHelp => { + let popup = Self::build_bookmarked_help_popup(); + self.state.popup = Some(popup); + } + InputEvent::Back => { + self.state.user_state.bookmarked_patchsets.patchset_index = 0; + self.set_current_screen(CurrentScreen::MailingListSelection); + } + InputEvent::NavigateDown => { + self.state + .user_state + .bookmarked_patchsets + .select_below_patchset(); + } + InputEvent::NavigateUp => { + self.state + .user_state + .bookmarked_patchsets + .select_above_patchset(); + } + InputEvent::OpenPatchsetDetails => { + debug!("loading patchset details from bookmarks"); + loading.start("Loading patchset".to_string()); + let result = self.open_patchset_details().await; + loading.stop()?; + // If a patchset has been bookmarked via the UI, b4 was already + // successful for it, but patchsets may also arrive here from + // other sources where the fetch could fail. + self.apply_open_patchset_result(CurrentScreen::BookmarkedPatchsets, result); + } + _ => {} } - InputEvent::OpenPatchsetDetails => { - debug!("loading patchset details from bookmarks"); - loading.start("Loading patchset".to_string()); - let result = app.open_patchset_details().await; - loading.stop()?; - // If a patchset has been bookmarked via the UI, b4 was already - // successful for it, but patchsets may also arrive here from - // other sources where the fetch could fail. - apply_open_patchset_result(app, CurrentScreen::BookmarkedPatchsets, result); - } - _ => {} + Ok(()) } - Ok(()) -} -pub fn generate_help_popup() -> AppPopup { - AppPopup::help() + pub fn build_bookmarked_help_popup() -> AppPopup { + AppPopup::help() .title("Bookmarked Patchsets") .description("This screen shows all the patchsets you have bookmarked.\nThis is quite useful to keep track of patchsets you are interested in take a look later.") .keybind("ESC", "Exit") @@ -60,4 +59,5 @@ pub fn generate_help_popup() -> AppPopup { .keybind("j/🡇", "Down") .keybind("k/🡅", "Up") .build() + } } diff --git a/src/app/flows/details_actions.rs b/src/app/flows/details_actions.rs index 62e3714..d907697 100644 --- a/src/app/flows/details_actions.rs +++ b/src/app/flows/details_actions.rs @@ -12,118 +12,119 @@ use crate::{ const USER_IO_ENTER_POLL_TIMEOUT: Duration = Duration::from_millis(200); -pub async fn handle_patchset_details( - app: &mut App, - input: InputEvent, - terminal_handle: &TerminalHandle, -) -> Result<()> { - if input == InputEvent::OpenKwOps { - return crate::app::flows::kw_ops::open_kw_ops(app).await; - } +impl App { + pub async fn handle_patchset_details( + &mut self, + input: InputEvent, + terminal_handle: &TerminalHandle, + ) -> Result<()> { + if input == InputEvent::OpenKwOps { + return self.open_kw_ops().await; + } - let patchset_details_and_actions = app - .state - .lore - .details - .as_mut() - .expect("invariant: details must be loaded before handling patchset details input"); + let patchset_details_and_actions = self + .state + .lore + .details + .as_mut() + .expect("invariant: details must be loaded before handling patchset details input"); - match input { - InputEvent::OpenHelp => { - let popup = generate_help_popup(); - app.state.popup = Some(popup); - } - InputEvent::Back => { - let ps_da_clone = patchset_details_and_actions.last_screen.clone(); - app.set_current_screen(ps_da_clone); - app.reset_details_actions(); - } - InputEvent::ToggleApply => { - patchset_details_and_actions.toggle_apply_action(); - } - InputEvent::PreviewScrollDown(amount) => { - let lines = preview_scroll_lines(amount, terminal_handle).await?; - patchset_details_and_actions.preview_scroll_down(lines); - } - InputEvent::PreviewScrollUp(amount) => { - let lines = preview_scroll_lines(amount, terminal_handle).await?; - patchset_details_and_actions.preview_scroll_up(lines); - } - InputEvent::PreviewPanLeft => { - patchset_details_and_actions.preview_pan_left(); - } - InputEvent::PreviewPanRight => { - patchset_details_and_actions.preview_pan_right(); - } - InputEvent::PreviewGoToBeginningOfLine => { - patchset_details_and_actions.go_to_beg_of_line(); - } - InputEvent::PreviewGoToFirstLine => { - patchset_details_and_actions.go_to_first_line(); - } - InputEvent::PreviewGoToLastLine => { - patchset_details_and_actions.go_to_last_line(); - } - InputEvent::TogglePreviewFullscreen => { - patchset_details_and_actions.toggle_preview_fullscreen(); - } - InputEvent::PreviewNext => { - patchset_details_and_actions.preview_next_patch(); - } - InputEvent::PreviewPrevious => { - patchset_details_and_actions.preview_previous_patch(); - } - InputEvent::ToggleBookmark => { - patchset_details_and_actions.toggle_bookmark_action(); - } - InputEvent::ToggleReplyWithReviewedBy => { - patchset_details_and_actions.toggle_reply_with_reviewed_by_action(false); - } - InputEvent::ToggleReplyWithReviewedByAll => { - patchset_details_and_actions.toggle_reply_with_reviewed_by_action(true); - } - InputEvent::ShowReviewTrailers => { - let popup = AppPopup::review_trailers(patchset_details_and_actions); - app.state.popup = Some(popup); - } - InputEvent::ConsolidatePatchsetActions => { - debug!( - requires_user_io = patchset_details_and_actions.actions_require_user_io(), - "consolidating patchset actions" - ); - if patchset_details_and_actions.actions_require_user_io() { - terminal_handle.setup_user_io().await?; - app.consolidate_patchset_actions().await?; - println!("\nPress ENTER continue..."); - while !terminal_handle - .wait_for_key_press(KeyCode::Enter, USER_IO_ENTER_POLL_TIMEOUT) - .await? - {} - terminal_handle.teardown_user_io().await?; - } else { - app.consolidate_patchset_actions().await?; - } - app.set_current_screen(CurrentScreen::PatchsetDetails); + match input { + InputEvent::OpenHelp => { + let popup = Self::build_details_help_popup(); + self.state.popup = Some(popup); + } + InputEvent::Back => { + let ps_da_clone = patchset_details_and_actions.last_screen.clone(); + self.set_current_screen(ps_da_clone); + self.reset_details_actions(); + } + InputEvent::ToggleApply => { + patchset_details_and_actions.toggle_apply_action(); + } + InputEvent::PreviewScrollDown(amount) => { + let lines = Self::count_preview_scroll_lines(amount, terminal_handle).await?; + patchset_details_and_actions.preview_scroll_down(lines); + } + InputEvent::PreviewScrollUp(amount) => { + let lines = Self::count_preview_scroll_lines(amount, terminal_handle).await?; + patchset_details_and_actions.preview_scroll_up(lines); + } + InputEvent::PreviewPanLeft => { + patchset_details_and_actions.preview_pan_left(); + } + InputEvent::PreviewPanRight => { + patchset_details_and_actions.preview_pan_right(); + } + InputEvent::PreviewGoToBeginningOfLine => { + patchset_details_and_actions.go_to_beg_of_line(); + } + InputEvent::PreviewGoToFirstLine => { + patchset_details_and_actions.go_to_first_line(); + } + InputEvent::PreviewGoToLastLine => { + patchset_details_and_actions.go_to_last_line(); + } + InputEvent::TogglePreviewFullscreen => { + patchset_details_and_actions.toggle_preview_fullscreen(); + } + InputEvent::PreviewNext => { + patchset_details_and_actions.preview_next_patch(); + } + InputEvent::PreviewPrevious => { + patchset_details_and_actions.preview_previous_patch(); + } + InputEvent::ToggleBookmark => { + patchset_details_and_actions.toggle_bookmark_action(); + } + InputEvent::ToggleReplyWithReviewedBy => { + patchset_details_and_actions.toggle_reply_with_reviewed_by_action(false); + } + InputEvent::ToggleReplyWithReviewedByAll => { + patchset_details_and_actions.toggle_reply_with_reviewed_by_action(true); + } + InputEvent::ShowReviewTrailers => { + let popup = AppPopup::review_trailers(patchset_details_and_actions); + self.state.popup = Some(popup); + } + InputEvent::ConsolidatePatchsetActions => { + debug!( + requires_user_io = patchset_details_and_actions.actions_require_user_io(), + "consolidating patchset actions" + ); + if patchset_details_and_actions.actions_require_user_io() { + terminal_handle.setup_user_io().await?; + self.consolidate_patchset_actions().await?; + println!("\nPress ENTER continue..."); + while !terminal_handle + .wait_for_key_press(KeyCode::Enter, USER_IO_ENTER_POLL_TIMEOUT) + .await? + {} + terminal_handle.teardown_user_io().await?; + } else { + self.consolidate_patchset_actions().await?; + } + self.set_current_screen(CurrentScreen::PatchsetDetails); + } + _ => {} } - _ => {} + Ok(()) } - Ok(()) -} -async fn preview_scroll_lines( - amount: ScrollAmount, - terminal_handle: &TerminalHandle, -) -> Result { - let (_, height) = terminal_handle.size().await?; - Ok(match amount { - ScrollAmount::Line => 1, - ScrollAmount::HalfPage => height as usize / 2, - ScrollAmount::Page => height as usize, - }) -} + async fn count_preview_scroll_lines( + amount: ScrollAmount, + terminal_handle: &TerminalHandle, + ) -> Result { + let (_, height) = terminal_handle.size().await?; + Ok(match amount { + ScrollAmount::Line => 1, + ScrollAmount::HalfPage => height as usize / 2, + ScrollAmount::Page => height as usize, + }) + } -pub fn generate_help_popup() -> AppPopup { - AppPopup::help() + pub fn build_details_help_popup() -> AppPopup { + AppPopup::help() .title("Patchset Details and Actions") .description("This screen displays the details of a patchset and allows you to perform actions on it.\nA series of actions are available to you, they are:\n - Bookmark: Save the patchset for later\n - Reply with Reviewed-by: Reply to the patchset with a Reviewed-by tag") .keybind("ESC", "Exit") @@ -145,4 +146,5 @@ pub fn generate_help_popup() -> AppPopup { .keybind("Ctrl+t", "Show code-review trailers details") .keybind("w", "Kw operations") .build() + } } diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 2c491d0..26b1fb6 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -6,80 +6,81 @@ use crate::{ input::event::InputEvent, }; -pub async fn handle_edit_config(app: &mut App, input: InputEvent) -> Result<()> { - let Some(is_editing) = app - .state - .config_state - .edit_config - .as_ref() - .map(|edit_config_state| edit_config_state.is_editing()) - else { - return Ok(()); - }; +impl App { + pub async fn handle_edit_config(&mut self, input: InputEvent) -> Result<()> { + let Some(is_editing) = self + .state + .config_state + .edit_config + .as_ref() + .map(|edit_config_state| edit_config_state.is_editing()) + else { + return Ok(()); + }; - match is_editing { - true => { - if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { - match input { - InputEvent::CancelConfigEdit => { - edit_config_state.clear_edit(); - edit_config_state.toggle_editing(); + match is_editing { + true => { + if let Some(edit_config_state) = self.state.config_state.edit_config.as_mut() { + match input { + InputEvent::CancelConfigEdit => { + edit_config_state.clear_edit(); + edit_config_state.toggle_editing(); + } + InputEvent::Backspace => { + edit_config_state.backspace_edit(); + } + InputEvent::TextInput(ch) => { + edit_config_state.append_edit(ch); + } + InputEvent::StageConfigEdit => { + edit_config_state.stage_edit(); + edit_config_state.clear_edit(); + edit_config_state.toggle_editing(); + } + InputEvent::NavigateLeft => { + edit_config_state.cycle_edit(false); + } + InputEvent::NavigateRight => { + edit_config_state.cycle_edit(true); + } + _ => {} } - InputEvent::Backspace => { - edit_config_state.backspace_edit(); - } - InputEvent::TextInput(ch) => { - edit_config_state.append_edit(ch); - } - InputEvent::StageConfigEdit => { - edit_config_state.stage_edit(); - edit_config_state.clear_edit(); + } + } + false => match input { + InputEvent::OpenHelp => { + let popup = Self::build_edit_config_help_popup(); + self.state.popup = Some(popup); + } + InputEvent::SaveConfig => { + debug!("saving edited configuration"); + self.consolidate_edit_config().await?; + self.reset_edit_config(); + self.set_current_screen(CurrentScreen::MailingListSelection); + } + InputEvent::EditConfigField => { + if let Some(edit_config_state) = self.state.config_state.edit_config.as_mut() { edit_config_state.toggle_editing(); } - InputEvent::NavigateLeft => { - edit_config_state.cycle_edit(false); + } + InputEvent::NavigateDown => { + if let Some(edit_config_state) = self.state.config_state.edit_config.as_mut() { + edit_config_state.highlight_next(); } - InputEvent::NavigateRight => { - edit_config_state.cycle_edit(true); + } + InputEvent::NavigateUp => { + if let Some(edit_config_state) = self.state.config_state.edit_config.as_mut() { + edit_config_state.highlight_prev(); } - _ => {} } - } + _ => {} + }, } - false => match input { - InputEvent::OpenHelp => { - let popup = generate_help_popup(); - app.state.popup = Some(popup); - } - InputEvent::SaveConfig => { - debug!("saving edited configuration"); - app.consolidate_edit_config().await?; - app.reset_edit_config(); - app.set_current_screen(CurrentScreen::MailingListSelection); - } - InputEvent::EditConfigField => { - if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { - edit_config_state.toggle_editing(); - } - } - InputEvent::NavigateDown => { - if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { - edit_config_state.highlight_next(); - } - } - InputEvent::NavigateUp => { - if let Some(edit_config_state) = app.state.config_state.edit_config.as_mut() { - edit_config_state.highlight_prev(); - } - } - _ => {} - }, + Ok(()) } - Ok(()) -} -pub fn generate_help_popup() -> AppPopup { - AppPopup::help() + pub fn build_edit_config_help_popup() -> AppPopup { + AppPopup::help() .title("Edit Config") .description("This screen allows you to edit the configuration options for patch-hub.\nKernel trees are added by editing the config file; this screen selects among existing keys.") .keybind("ESC / q", "Save and exit") @@ -89,6 +90,7 @@ pub fn generate_help_popup() -> AppPopup { .keybind("k/🡅", "Up") .keybind("←/→", "Cycle the target kernel tree while editing that row") .build() + } } #[cfg(test)] @@ -230,7 +232,7 @@ mod tests { description, formatted_keybinds, .. - } = generate_help_popup() + } = App::build_edit_config_help_popup() else { panic!("expected help popup"); }; @@ -249,20 +251,20 @@ mod tests { app.init_edit_config(); for _ in 0..11 { - handle_edit_config(&mut app, InputEvent::NavigateDown) + app.handle_edit_config(InputEvent::NavigateDown) .await .unwrap(); } - handle_edit_config(&mut app, InputEvent::EditConfigField) + app.handle_edit_config(InputEvent::EditConfigField) .await .unwrap(); - handle_edit_config(&mut app, InputEvent::NavigateRight) + app.handle_edit_config(InputEvent::NavigateRight) .await .unwrap(); - handle_edit_config(&mut app, InputEvent::StageConfigEdit) + app.handle_edit_config(InputEvent::StageConfigEdit) .await .unwrap(); - handle_edit_config(&mut app, InputEvent::SaveConfig) + app.handle_edit_config(InputEvent::SaveConfig) .await .unwrap(); @@ -291,7 +293,7 @@ mod tests { ); app.init_edit_config(); - handle_edit_config(&mut app, InputEvent::SaveConfig) + app.handle_edit_config(InputEvent::SaveConfig) .await .unwrap(); diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 0e2f70a..321c546 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -25,253 +25,6 @@ use crate::{ /// far larger than this; the TUI only needs a bounded tail. pub(crate) const LOG_TAIL_MAX_BYTES: usize = 64 * 1024; -/// Refresh the KwOps log panel from the current job's log file. -/// -/// A missing file is normal just after accept (`Waiting for kw output…` -/// is projected from an empty tail). Other read errors become a -/// non-fatal diagnostic in the panel and do not change job status. -/// Returns whether the displayed text changed. -/// -/// The read runs on the blocking pool: the log is a real file, and -/// AppActor's loop must not stall on disk I/O. -pub(crate) async fn refresh_kw_ops_log_tail(app: &mut App) -> bool { - if app.state.kw.ops.is_none() { - return false; - } - let Some(path) = app - .state - .kw - .status - .as_ref() - .and_then(|status| status.job.log_path()) - .map(std::path::PathBuf::from) - else { - return false; - }; - let fs = Arc::clone(&app.services.fs); - let result = - tokio::task::spawn_blocking(move || fs.read_tail_to_string(&path, LOG_TAIL_MAX_BYTES)) - .await; - let text = match result { - Ok(read) => tail_text_from_read(read), - Err(error) => format!("(could not read log: {error})"), - }; - let Some(ops) = app.state.kw.ops.as_mut() else { - return false; - }; - assign_log_tail(ops, text) -} - -fn tail_text_from_read(result: Result) -> String { - match result { - Ok(text) => text, - Err(FileSystemError::IoError(error)) if error.kind() == std::io::ErrorKind::NotFound => { - String::new() - } - Err(error) => format!("(could not read log: {error})"), - } -} - -fn assign_log_tail(ops: &mut KwOpsState, text: String) -> bool { - if ops.log_tail == text { - false - } else { - ops.log_tail = text; - true - } -} - -/// Projects a kw snapshot into App state and releases the optimistic -/// start lock once the actor has left Idle. -pub(crate) fn apply_kw_snapshot(app: &mut App, snapshot: KwStatusSnapshot) { - if let Some(ops) = app.state.kw.ops.as_mut() { - if !matches!(snapshot.job, KwJobStatus::Idle) { - ops.start_requested = false; - } - } - app.state.kw.status = Some(snapshot); -} - -fn snapshot_job_is_terminal(job: &KwJobStatus) -> bool { - matches!( - job, - KwJobStatus::Succeeded { .. } | KwJobStatus::Failed { .. } | KwJobStatus::Cancelled { .. } - ) -} - -/// Re-probe deploy-alone after a job ends so the (d) label tracks the -/// in-session build record instead of staying on the pre-build snapshot. -pub(crate) async fn apply_kw_snapshot_refreshing_readiness( - app: &mut App, - snapshot: KwStatusSnapshot, -) { - let refresh = snapshot_job_is_terminal(&snapshot.job) - && app.state.navigation.current_screen == CurrentScreen::KwOps; - apply_kw_snapshot(app, snapshot); - if refresh { - refresh_kw_ops_readiness(app).await; - } -} - -/// Keyboard-only fallback: pull status when the watch is unavailable. -pub(crate) async fn poll_kw_status(app: &mut App) -> bool { - let Some(kw) = app.services.kw.clone() else { - return false; - }; - let Ok(snapshot) = kw.get_status().await else { - return false; - }; - let changed = app.state.kw.status.as_ref() != Some(&snapshot); - apply_kw_snapshot_refreshing_readiness(app, snapshot).await; - changed -} - -/// Seed the projection from GetStatus after the watch is gone. -/// -/// A successful query lets the poll arm engage if a job is already -/// running (the subscribe-failed-with-live-job corner). A failed query -/// clears status so a dead actor cannot leave a stale "building" -/// indicator. -pub(crate) async fn fallback_kw_status(app: &mut App) { - let Some(kw) = app.services.kw.clone() else { - app.state.kw.status = None; - return; - }; - match kw.get_status().await { - Ok(snapshot) => apply_kw_snapshot_refreshing_readiness(app, snapshot).await, - Err(_) => app.state.kw.status = None, - } -} - -pub async fn handle_kw_ops(app: &mut App, input: InputEvent) -> Result<()> { - let editing = app.state.kw.ops.as_ref().is_some_and(|ops| ops.editing); - if editing { - match input { - InputEvent::CancelKwOpsEdit => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.cancel_edit(); - } - } - InputEvent::Backspace => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.backspace_edit(); - } - } - InputEvent::TextInput(ch) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.append_edit(ch); - } - } - InputEvent::StageKwOpsEdit => { - let focus = app.state.kw.ops.as_ref().map(|ops| ops.focus); - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.commit_edit(); - } - if focus == Some(KwOpsFocus::Branch) { - refresh_kw_ops_readiness(app).await; - } - } - _ => {} - } - return Ok(()); - } - - match input { - InputEvent::OpenHelp => { - app.state.popup = Some(generate_help_popup()); - } - InputEvent::Back => { - app.set_current_screen(CurrentScreen::PatchsetDetails); - } - InputEvent::NavigateDown => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.highlight_next(); - } - } - InputEvent::NavigateUp => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.highlight_prev(); - } - } - InputEvent::EditKwOpsField => { - if !job_is_busy(app) { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.begin_edit(); - } - } - } - InputEvent::StartKwBuild => start_job(app, KwStartKind::Build).await?, - InputEvent::StartKwDeploy => start_job(app, KwStartKind::Deploy).await?, - InputEvent::StartKwBuildThenDeploy => start_job(app, KwStartKind::BuildThenDeploy).await?, - InputEvent::CancelKwJob => cancel_job(app).await?, - InputEvent::RestoreKwBranch => restore_branch(app).await?, - _ => {} - } - Ok(()) -} - -/// Opens KwOps from Patchset Details. Failures keep Details active. -pub async fn open_kw_ops(app: &mut App) -> Result<()> { - let Some(details) = app.state.lore.details.as_ref() else { - return Ok(()); - }; - let patchset_title = details.representative_patch.title().clone(); - let message_id = details.representative_patch.message_id().href.clone(); - - let Some(kw) = app.services.kw.clone() else { - app.state.popup = Some(AppPopup::info( - "Kw operations unavailable", - "kw jobs require a Unix kw actor, which is not attached.", - )); - return Ok(()); - }; - - let Some(kernel_tree_id) = app.state.config.target_kernel_tree().clone() else { - app.state.popup = Some(AppPopup::info( - "Kw operations unavailable", - "No target kernel tree is configured. Set target_kernel_tree in the config.", - )); - return Ok(()); - }; - let Some(tree) = app.state.config.get_kernel_tree(&kernel_tree_id).cloned() else { - app.state.popup = Some(AppPopup::info( - "Kw operations unavailable", - format!("Target kernel tree '{kernel_tree_id}' is not in the configuration."), - )); - return Ok(()); - }; - - match kw.get_readiness(&kernel_tree_id, &tree, None).await { - Ok(readiness) => { - let reuse = app.state.kw.ops.as_ref().is_some_and(|ops| { - ops.message_id == message_id && ops.kernel_tree_id == kernel_tree_id - }); - if reuse { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.reenter(patchset_title, tree, readiness); - } - } else { - app.state.kw.ops = Some(KwOpsState::new( - patchset_title, - message_id, - kernel_tree_id, - tree, - readiness, - )); - } - app.set_current_screen(CurrentScreen::KwOps); - refresh_kw_ops_log_tail(app).await; - } - Err(error) => { - app.state.popup = Some(AppPopup::info( - "Kw operations unavailable", - error.to_string(), - )); - } - } - Ok(()) -} - #[derive(Clone, Copy)] enum KwStartKind { Build, @@ -304,251 +57,508 @@ impl KwStartKind { } } -async fn start_job(app: &mut App, kind: KwStartKind) -> Result<()> { - if job_is_busy(app) { - return Ok(()); - } - let Some(ops) = app.state.kw.ops.as_ref() else { - return Ok(()); - }; - let branch = ops.branch.trim().to_string(); - if branch.is_empty() { - let body = if ops.head_unreadable { - format!( - "HEAD is detached or unverifiable. Type a branch name before starting a {}.", - kind.action_word() - ) - } else { - format!("Set a branch before starting a {}.", kind.action_word()) +impl App { + /// Refresh the KwOps log panel from the current job's log file. + /// + /// A missing file is normal just after accept (`Waiting for kw output…` + /// is projected from an empty tail). Other read errors become a + /// non-fatal diagnostic in the panel and do not change job status. + /// Returns whether the displayed text changed. + /// + /// The read runs on the blocking pool: the log is a real file, and + /// AppActor's loop must not stall on disk I/O. + pub(crate) async fn refresh_kw_ops_log_tail(&mut self) -> bool { + if self.state.kw.ops.is_none() { + return false; + } + let Some(path) = self + .state + .kw + .status + .as_ref() + .and_then(|status| status.job.log_path()) + .map(std::path::PathBuf::from) + else { + return false; + }; + let fs = Arc::clone(&self.services.fs); + let result = + tokio::task::spawn_blocking(move || fs.read_tail_to_string(&path, LOG_TAIL_MAX_BYTES)) + .await; + let text = match result { + Ok(read) => Self::map_tail_read(read), + Err(error) => format!("(could not read log: {error})"), }; - app.state.popup = Some(AppPopup::info(kind.title(), body)); - return Ok(()); + let Some(ops) = self.state.kw.ops.as_mut() else { + return false; + }; + Self::assign_log_tail(ops, text) } - // Record/tree refusals before the boot-once confirm so a deploy - // without a build does not ask the user to proceed, then refuse. - if matches!(kind, KwStartKind::Deploy) { - if let Err(reason) = &ops.readiness.deploy_alone { - app.state.popup = Some(AppPopup::info(kind.title(), reason.to_string())); - return Ok(()); + + fn map_tail_read(result: Result) -> String { + match result { + Ok(text) => text, + Err(FileSystemError::IoError(error)) + if error.kind() == std::io::ErrorKind::NotFound => + { + String::new() + } + Err(error) => format!("(could not read log: {error})"), } } - if kind.pending_kind().is_some() && needs_boot_once_confirm(ops) { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.pending_deploy = kind.pending_kind(); + + fn assign_log_tail(ops: &mut KwOpsState, text: String) -> bool { + if ops.log_tail == text { + false + } else { + ops.log_tail = text; + true } - app.state.popup = Some(AppPopup::boot_once_warning()); - return Ok(()); } - let reboot = app.state.config.kw_reboot_after_deploy(); - let force = app.state.config.kw_deploy_force(); - let Some(ops) = app.state.kw.ops.as_ref() else { - return Ok(()); - }; - let request = StartRequest { - kernel_tree_id: ops.kernel_tree_id.clone(), - tree: ops.tree.clone(), - branch, - extra_args: ops.extra_arg_tokens(), - deploy: kind.pending_kind().map(|_| DeployOptions { - reboot, - force, - boot_once_acknowledged: ops.boot_once_acknowledged, - }), - }; - let Some(kw) = app.services.kw.clone() else { - app.state.popup = Some(AppPopup::info( - kind.title(), - "kw jobs require a Unix kw actor, which is not attached.", - )); - return Ok(()); - }; - let result = match kind { - KwStartKind::Build => kw.start_build(request).await, - KwStartKind::Deploy => kw.start_deploy(request).await, - KwStartKind::BuildThenDeploy => kw.start_build_then_deploy(request).await, - }; - match result { - Ok(()) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.cancel_requested = false; - ops.start_requested = true; - } - // Apply the actor's snapshot immediately so keyboard-only - // mode (no watch) cannot latch on start_requested, and so a - // second Start in the same loop turn sees the job as busy. - if let Ok(snapshot) = kw.get_status().await { - apply_kw_snapshot(app, snapshot); + /// Projects a kw snapshot into App state and releases the optimistic + /// start lock once the actor has left Idle. + pub(crate) fn apply_kw_snapshot(&mut self, snapshot: KwStatusSnapshot) { + if let Some(ops) = self.state.kw.ops.as_mut() { + if !matches!(snapshot.job, KwJobStatus::Idle) { + ops.start_requested = false; } } - Err(KwStartError::BootOnceNotAcknowledged) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.pending_deploy = kind.pending_kind(); - } - app.state.popup = Some(AppPopup::boot_once_warning()); - } - Err(error) => { - app.state.popup = Some(AppPopup::info(kind.title(), error.to_string())); + self.state.kw.status = Some(snapshot); + } + + fn is_snapshot_job_terminal(job: &KwJobStatus) -> bool { + matches!( + job, + KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } + ) + } + + /// Re-probe deploy-alone after a job ends so the (d) label tracks the + /// in-session build record instead of staying on the pre-build snapshot. + pub(crate) async fn apply_kw_snapshot_refreshing_readiness( + &mut self, + snapshot: KwStatusSnapshot, + ) { + let refresh = Self::is_snapshot_job_terminal(&snapshot.job) + && self.state.navigation.current_screen == CurrentScreen::KwOps; + self.apply_kw_snapshot(snapshot); + if refresh { + self.refresh_kw_ops_readiness().await; } } - Ok(()) -} -fn needs_boot_once_confirm(ops: &KwOpsState) -> bool { - matches!( - ops.readiness.boot_once, - BootOnceState::On | BootOnceState::Unknown - ) && !ops.boot_once_acknowledged -} + /// Keyboard-only fallback: pull status when the watch is unavailable. + pub(crate) async fn poll_kw_status(&mut self) -> bool { + let Some(kw) = self.services.kw.clone() else { + return false; + }; + let Ok(snapshot) = kw.get_status().await else { + return false; + }; + let changed = self.state.kw.status.as_ref() != Some(&snapshot); + self.apply_kw_snapshot_refreshing_readiness(snapshot).await; + changed + } -async fn refresh_kw_ops_readiness(app: &mut App) { - let Some(kw) = app.services.kw.clone() else { - return; - }; - let Some((kernel_tree_id, tree, branch)) = app.state.kw.ops.as_ref().map(|ops| { - ( - ops.kernel_tree_id.clone(), - ops.tree.clone(), - ops.branch.clone(), - ) - }) else { - return; - }; - let for_branch = { - let trimmed = branch.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) + /// Seed the projection from GetStatus after the watch is gone. + /// + /// A successful query lets the poll arm engage if a job is already + /// running (the subscribe-failed-with-live-job corner). A failed query + /// clears status so a dead actor cannot leave a stale "building" + /// indicator. + pub(crate) async fn fallback_kw_status(&mut self) { + let Some(kw) = self.services.kw.clone() else { + self.state.kw.status = None; + return; + }; + match kw.get_status().await { + Ok(snapshot) => self.apply_kw_snapshot_refreshing_readiness(snapshot).await, + Err(_) => self.state.kw.status = None, } - }; - match kw - .get_readiness(&kernel_tree_id, &tree, for_branch.as_deref()) - .await - { - Ok(readiness) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.readiness = readiness; + } + + pub async fn handle_kw_ops(&mut self, input: InputEvent) -> Result<()> { + let editing = self.state.kw.ops.as_ref().is_some_and(|ops| ops.editing); + if editing { + match input { + InputEvent::CancelKwOpsEdit => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.cancel_edit(); + } + } + InputEvent::Backspace => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.backspace_edit(); + } + } + InputEvent::TextInput(ch) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.append_edit(ch); + } + } + InputEvent::StageKwOpsEdit => { + let focus = self.state.kw.ops.as_ref().map(|ops| ops.focus); + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.commit_edit(); + } + if focus == Some(KwOpsFocus::Branch) { + self.refresh_kw_ops_readiness().await; + } + } + _ => {} } + return Ok(()); } - Err(error) => { - app.state.popup = Some(AppPopup::info( - "Cannot refresh kw status", - error.to_string(), - )); + + match input { + InputEvent::OpenHelp => { + self.state.popup = Some(Self::build_kw_ops_help_popup()); + } + InputEvent::Back => { + self.set_current_screen(CurrentScreen::PatchsetDetails); + } + InputEvent::NavigateDown => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.highlight_next(); + } + } + InputEvent::NavigateUp => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.highlight_prev(); + } + } + InputEvent::EditKwOpsField => { + if !self.is_job_busy() { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.begin_edit(); + } + } + } + InputEvent::StartKwBuild => self.start_job(KwStartKind::Build).await?, + InputEvent::StartKwDeploy => self.start_job(KwStartKind::Deploy).await?, + InputEvent::StartKwBuildThenDeploy => { + self.start_job(KwStartKind::BuildThenDeploy).await? + } + InputEvent::CancelKwJob => self.cancel_job().await?, + InputEvent::RestoreKwBranch => self.restore_branch().await?, + _ => {} } + Ok(()) } -} -async fn cancel_job(app: &mut App) -> Result<()> { - if !job_is_running(app) { - return Ok(()); + /// Opens KwOps from Patchset Details. Failures keep Details active. + pub async fn open_kw_ops(&mut self) -> Result<()> { + let Some(details) = self.state.lore.details.as_ref() else { + return Ok(()); + }; + let patchset_title = details.representative_patch.title().clone(); + let message_id = details.representative_patch.message_id().href.clone(); + + let Some(kw) = self.services.kw.clone() else { + self.state.popup = Some(AppPopup::info( + "Kw operations unavailable", + "kw jobs require a Unix kw actor, which is not attached.", + )); + return Ok(()); + }; + + let Some(kernel_tree_id) = self.state.config.target_kernel_tree().clone() else { + self.state.popup = Some(AppPopup::info( + "Kw operations unavailable", + "No target kernel tree is configured. Set target_kernel_tree in the config.", + )); + return Ok(()); + }; + let Some(tree) = self.state.config.get_kernel_tree(&kernel_tree_id).cloned() else { + self.state.popup = Some(AppPopup::info( + "Kw operations unavailable", + format!("Target kernel tree '{kernel_tree_id}' is not in the configuration."), + )); + return Ok(()); + }; + + match kw.get_readiness(&kernel_tree_id, &tree, None).await { + Ok(readiness) => { + let reuse = self.state.kw.ops.as_ref().is_some_and(|ops| { + ops.message_id == message_id && ops.kernel_tree_id == kernel_tree_id + }); + if reuse { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.reenter(patchset_title, tree, readiness); + } + } else { + self.state.kw.ops = Some(KwOpsState::new( + patchset_title, + message_id, + kernel_tree_id, + tree, + readiness, + )); + } + self.set_current_screen(CurrentScreen::KwOps); + self.refresh_kw_ops_log_tail().await; + } + Err(error) => { + self.state.popup = Some(AppPopup::info( + "Kw operations unavailable", + error.to_string(), + )); + } + } + Ok(()) } - let Some(kw) = app.services.kw.clone() else { - return Ok(()); - }; - match kw.cancel().await { - Ok(()) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.cancel_requested = true; + + async fn start_job(&mut self, kind: KwStartKind) -> Result<()> { + if self.is_job_busy() { + return Ok(()); + } + let Some(ops) = self.state.kw.ops.as_ref() else { + return Ok(()); + }; + let branch = ops.branch.trim().to_string(); + if branch.is_empty() { + let body = if ops.head_unreadable { + format!( + "HEAD is detached or unverifiable. Type a branch name before starting a {}.", + kind.action_word() + ) + } else { + format!("Set a branch before starting a {}.", kind.action_word()) + }; + self.state.popup = Some(AppPopup::info(kind.title(), body)); + return Ok(()); + } + // Record/tree refusals before the boot-once confirm so a deploy + // without a build does not ask the user to proceed, then refuse. + if matches!(kind, KwStartKind::Deploy) { + if let Err(reason) = &ops.readiness.deploy_alone { + self.state.popup = Some(AppPopup::info(kind.title(), reason.to_string())); + return Ok(()); } } - Err(KwError::NoJobRunning) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.cancel_requested = false; + if kind.pending_kind().is_some() && Self::needs_boot_once_confirm(ops) { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.pending_deploy = kind.pending_kind(); } + self.state.popup = Some(AppPopup::boot_once_warning()); + return Ok(()); } - Err(error) => { - app.state.popup = Some(AppPopup::info("Cannot cancel job", error.to_string())); + + let reboot = self.state.config.kw_reboot_after_deploy(); + let force = self.state.config.kw_deploy_force(); + let Some(ops) = self.state.kw.ops.as_ref() else { + return Ok(()); + }; + let request = StartRequest { + kernel_tree_id: ops.kernel_tree_id.clone(), + tree: ops.tree.clone(), + branch, + extra_args: ops.extra_arg_tokens(), + deploy: kind.pending_kind().map(|_| DeployOptions { + reboot, + force, + boot_once_acknowledged: ops.boot_once_acknowledged, + }), + }; + let Some(kw) = self.services.kw.clone() else { + self.state.popup = Some(AppPopup::info( + kind.title(), + "kw jobs require a Unix kw actor, which is not attached.", + )); + return Ok(()); + }; + let result = match kind { + KwStartKind::Build => kw.start_build(request).await, + KwStartKind::Deploy => kw.start_deploy(request).await, + KwStartKind::BuildThenDeploy => kw.start_build_then_deploy(request).await, + }; + match result { + Ok(()) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.cancel_requested = false; + ops.start_requested = true; + } + // Apply the actor's snapshot immediately so keyboard-only + // mode (no watch) cannot latch on start_requested, and so a + // second Start in the same loop turn sees the job as busy. + if let Ok(snapshot) = kw.get_status().await { + self.apply_kw_snapshot(snapshot); + } + } + Err(KwStartError::BootOnceNotAcknowledged) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.pending_deploy = kind.pending_kind(); + } + self.state.popup = Some(AppPopup::boot_once_warning()); + } + Err(error) => { + self.state.popup = Some(AppPopup::info(kind.title(), error.to_string())); + } } + Ok(()) } - Ok(()) -} -async fn restore_branch(app: &mut App) -> Result<()> { - if job_is_running(app) { - return Ok(()); + fn needs_boot_once_confirm(ops: &KwOpsState) -> bool { + matches!( + ops.readiness.boot_once, + BootOnceState::On | BootOnceState::Unknown + ) && !ops.boot_once_acknowledged } - let Some(restore) = app - .state - .kw - .status - .as_ref() - .and_then(|status| status.restore_branch.clone()) - else { - return Ok(()); - }; - let Some(kw) = app.services.kw.clone() else { - return Ok(()); - }; - match kw.restore_previous_branch().await { - Ok(()) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.branch = restore; - ops.head_unreadable = false; + + async fn refresh_kw_ops_readiness(&mut self) { + let Some(kw) = self.services.kw.clone() else { + return; + }; + let Some((kernel_tree_id, tree, branch)) = self.state.kw.ops.as_ref().map(|ops| { + ( + ops.kernel_tree_id.clone(), + ops.tree.clone(), + ops.branch.clone(), + ) + }) else { + return; + }; + let for_branch = { + let trimmed = branch.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }; + match kw + .get_readiness(&kernel_tree_id, &tree, for_branch.as_deref()) + .await + { + Ok(readiness) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.readiness = readiness; + } + } + Err(error) => { + self.state.popup = Some(AppPopup::info( + "Cannot refresh kw status", + error.to_string(), + )); } - } - Err(error) => { - app.state.popup = Some(AppPopup::info("Cannot restore branch", error.to_string())); } } - Ok(()) -} -fn job_is_running(app: &App) -> bool { - matches!( - app.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) -} + async fn cancel_job(&mut self) -> Result<()> { + if !self.is_job_running() { + return Ok(()); + } + let Some(kw) = self.services.kw.clone() else { + return Ok(()); + }; + match kw.cancel().await { + Ok(()) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.cancel_requested = true; + } + } + Err(KwError::NoJobRunning) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.cancel_requested = false; + } + } + Err(error) => { + self.state.popup = Some(AppPopup::info("Cannot cancel job", error.to_string())); + } + } + Ok(()) + } -fn job_is_busy(app: &App) -> bool { - job_is_running(app) - || app + async fn restore_branch(&mut self) -> Result<()> { + if self.is_job_running() { + return Ok(()); + } + let Some(restore) = self .state .kw - .ops + .status .as_ref() - .is_some_and(|ops| ops.start_requested) -} + .and_then(|status| status.restore_branch.clone()) + else { + return Ok(()); + }; + let Some(kw) = self.services.kw.clone() else { + return Ok(()); + }; + match kw.restore_previous_branch().await { + Ok(()) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.branch = restore; + ops.head_unreadable = false; + } + } + Err(error) => { + self.state.popup = Some(AppPopup::info("Cannot restore branch", error.to_string())); + } + } + Ok(()) + } -/// Records that the user confirmed boot-once and re-issues the pending -/// StartDeploy / StartBuildThenDeploy against that acknowledgement. -pub(crate) async fn resume_pending_deploy(app: &mut App) -> Result<()> { - let Some(ops) = app.state.kw.ops.as_mut() else { - return Ok(()); - }; - ops.boot_once_acknowledged = true; - let Some(kind) = ops.pending_deploy.take() else { - return Ok(()); - }; - match kind { - DeployStartKind::Deploy => start_job(app, KwStartKind::Deploy).await, - DeployStartKind::BuildThenDeploy => start_job(app, KwStartKind::BuildThenDeploy).await, + fn is_job_running(&self) -> bool { + matches!( + self.state.kw.status.as_ref().map(|status| &status.job), + Some(KwJobStatus::Running { .. }) + ) } -} -/// Drops a deploy start that was waiting on the boot-once confirm popup. -pub(crate) fn clear_pending_deploy(app: &mut App) { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.pending_deploy = None; + fn is_job_busy(&self) -> bool { + self.is_job_running() + || self + .state + .kw + .ops + .as_ref() + .is_some_and(|ops| ops.start_requested) } -} -pub fn generate_help_popup() -> AppPopup { - AppPopup::help() - .title("Kw operations") - .description("Start a kw build and/or remote deploy on the configured target kernel tree.") - .keybind("ESC / q", "Return to patchset details") - .keybind("j/k", "Move between branch and extra arguments") - .keybind("e / ENTER", "Edit the focused field") - .keybind("b", "Start build") - .keybind("d", "Start deploy") - .keybind("D", "Start build then deploy") - .keybind("c", "Cancel the running job") - .keybind("r", "Restore the previous branch") - .keybind("?", "Show this help screen") - .build() + /// Records that the user confirmed boot-once and re-issues the pending + /// StartDeploy / StartBuildThenDeploy against that acknowledgement. + pub(crate) async fn resume_pending_deploy(&mut self) -> Result<()> { + let Some(ops) = self.state.kw.ops.as_mut() else { + return Ok(()); + }; + ops.boot_once_acknowledged = true; + let Some(kind) = ops.pending_deploy.take() else { + return Ok(()); + }; + match kind { + DeployStartKind::Deploy => self.start_job(KwStartKind::Deploy).await, + DeployStartKind::BuildThenDeploy => self.start_job(KwStartKind::BuildThenDeploy).await, + } + } + + /// Drops a deploy start that was waiting on the boot-once confirm popup. + pub(crate) fn clear_pending_deploy(&mut self) { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.pending_deploy = None; + } + } + + pub fn build_kw_ops_help_popup() -> AppPopup { + AppPopup::help() + .title("Kw operations") + .description( + "Start a kw build and/or remote deploy on the configured target kernel tree.", + ) + .keybind("ESC / q", "Return to patchset details") + .keybind("j/k", "Move between branch and extra arguments") + .keybind("e / ENTER", "Edit the focused field") + .keybind("b", "Start build") + .keybind("d", "Start deploy") + .keybind("D", "Start build then deploy") + .keybind("c", "Cancel the running job") + .keybind("r", "Restore the previous branch") + .keybind("?", "Show this help screen") + .build() + } } #[cfg(test)] @@ -593,7 +603,7 @@ mod tests { fn missing_log_is_an_empty_tail_not_a_diagnostic() { let missing = FileSystemError::IoError(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")); - assert_eq!("", tail_text_from_read(Err(missing))); + assert_eq!("", App::map_tail_read(Err(missing))); } #[test] @@ -602,7 +612,7 @@ mod tests { std::io::ErrorKind::PermissionDenied, "denied", )); - let text = tail_text_from_read(Err(error)); + let text = App::map_tail_read(Err(error)); assert!(text.contains("could not read log")); assert!(text.contains("denied")); } @@ -610,9 +620,12 @@ mod tests { #[test] fn assign_log_tail_reports_whether_text_changed() { let mut ops = sample_ops(); - assert!(assign_log_tail(&mut ops, "cc1: compiling".to_string())); - assert!(!assign_log_tail(&mut ops, "cc1: compiling".to_string())); - assert!(assign_log_tail(&mut ops, "done".to_string())); + assert!(App::assign_log_tail(&mut ops, "cc1: compiling".to_string())); + assert!(!App::assign_log_tail( + &mut ops, + "cc1: compiling".to_string() + )); + assert!(App::assign_log_tail(&mut ops, "done".to_string())); assert_eq!("done", ops.log_tail); } @@ -622,7 +635,7 @@ mod tests { description, formatted_keybinds, .. - } = generate_help_popup() + } = App::build_kw_ops_help_popup() else { panic!("expected help popup"); }; diff --git a/src/app/flows/latest.rs b/src/app/flows/latest.rs index 3953585..afe62d8 100644 --- a/src/app/flows/latest.rs +++ b/src/app/flows/latest.rs @@ -7,83 +7,92 @@ use crate::{ input::event::InputEvent, }; -use super::open_patchset::apply_open_patchset_result; - -pub async fn handle_latest_patchsets( - app: &mut App, - input: InputEvent, - loading: &mut dyn LoadingIndicator, -) -> Result<()> { - match input { - InputEvent::OpenHelp => { - let popup = generate_help_popup(); - app.state.popup = Some(popup); - } - InputEvent::Back => { - app.reset_latest_patchsets(); - app.set_current_screen(CurrentScreen::MailingListSelection); - } - InputEvent::NavigateDown => { - app.state - .lore - .latest_patchsets - .as_mut() - .expect("invariant: latest_patchsets must be initialised on LatestPatchsets screen") - .select_below_patchset(); - } - InputEvent::NavigateUp => { - app.state - .lore - .latest_patchsets - .as_mut() - .expect("invariant: latest_patchsets must be initialised on LatestPatchsets screen") - .select_above_patchset(); - } - InputEvent::NextPage => { - let list_name = app - .state - .lore - .latest_patchsets - .as_ref() - .expect("invariant: latest_patchsets must be initialised on LatestPatchsets screen") - .target_list() - .to_string(); - debug!(list = list_name, "fetching next page of patchsets"); - loading.start(format!("Fetching patchsets from {list_name}")); - app.state - .lore - .latest_patchsets - .as_mut() - .expect("invariant: latest_patchsets must be initialised on LatestPatchsets screen") - .increment_page(); - let result = app.fetch_latest_current_page().await; - loading.stop()?; - result?; +impl App { + pub async fn handle_latest_patchsets( + &mut self, + input: InputEvent, + loading: &mut dyn LoadingIndicator, + ) -> Result<()> { + match input { + InputEvent::OpenHelp => { + let popup = Self::build_latest_help_popup(); + self.state.popup = Some(popup); + } + InputEvent::Back => { + self.reset_latest_patchsets(); + self.set_current_screen(CurrentScreen::MailingListSelection); + } + InputEvent::NavigateDown => { + self.state + .lore + .latest_patchsets + .as_mut() + .expect( + "invariant: latest_patchsets must be initialised on LatestPatchsets screen", + ) + .select_below_patchset(); + } + InputEvent::NavigateUp => { + self.state + .lore + .latest_patchsets + .as_mut() + .expect( + "invariant: latest_patchsets must be initialised on LatestPatchsets screen", + ) + .select_above_patchset(); + } + InputEvent::NextPage => { + let list_name = self + .state + .lore + .latest_patchsets + .as_ref() + .expect( + "invariant: latest_patchsets must be initialised on LatestPatchsets screen", + ) + .target_list() + .to_string(); + debug!(list = list_name, "fetching next page of patchsets"); + loading.start(format!("Fetching patchsets from {list_name}")); + self.state + .lore + .latest_patchsets + .as_mut() + .expect( + "invariant: latest_patchsets must be initialised on LatestPatchsets screen", + ) + .increment_page(); + let result = self.fetch_latest_current_page().await; + loading.stop()?; + result?; + } + InputEvent::PreviousPage => { + self.state + .lore + .latest_patchsets + .as_mut() + .expect( + "invariant: latest_patchsets must be initialised on LatestPatchsets screen", + ) + .decrement_page(); + // Reload from cache (no network call since LoreAPI caches all pages) + self.fetch_latest_current_page().await?; + } + InputEvent::OpenPatchsetDetails => { + debug!("loading patchset details from latest"); + loading.start("Loading patchset".to_string()); + let result = self.open_patchset_details().await; + loading.stop()?; + self.apply_open_patchset_result(CurrentScreen::LatestPatchsets, result); + } + _ => {} } - InputEvent::PreviousPage => { - app.state - .lore - .latest_patchsets - .as_mut() - .expect("invariant: latest_patchsets must be initialised on LatestPatchsets screen") - .decrement_page(); - // Reload from cache (no network call since LoreAPI caches all pages) - app.fetch_latest_current_page().await?; - } - InputEvent::OpenPatchsetDetails => { - debug!("loading patchset details from latest"); - loading.start("Loading patchset".to_string()); - let result = app.open_patchset_details().await; - loading.stop()?; - apply_open_patchset_result(app, CurrentScreen::LatestPatchsets, result); - } - _ => {} + Ok(()) } - Ok(()) -} -pub fn generate_help_popup() -> AppPopup { - AppPopup::help() + pub fn build_latest_help_popup() -> AppPopup { + AppPopup::help() .title("Latest Patchsets") .description("This screen allows you to see a list of the latest patchsets from a mailing list.\nYou might also be able to view the details of a patchset.") .keybind("ESC", "Exit") @@ -94,4 +103,5 @@ pub fn generate_help_popup() -> AppPopup { .keybind("l/🡆", "Next page") .keybind("h/🡄", "Previous page") .build() + } } diff --git a/src/app/flows/mail_list.rs b/src/app/flows/mail_list.rs index 82ac011..cbe8ab4 100644 --- a/src/app/flows/mail_list.rs +++ b/src/app/flows/mail_list.rs @@ -8,95 +8,102 @@ use crate::{ input::event::InputEvent, }; -pub async fn handle_mailing_list_selection( - app: &mut App, - input: InputEvent, - loading: &mut dyn LoadingIndicator, -) -> Result> { - match input { - InputEvent::OpenHelp => { - let popup = generate_help_popup(); - app.state.popup = Some(popup); - } - InputEvent::OpenLatestPatchsets => { - if app - .state - .lore - .mailing_list_selection - .has_valid_target_list() - { - app.init_latest_patchsets(); - let list_name = app +impl App { + pub async fn handle_mailing_list_selection( + &mut self, + input: InputEvent, + loading: &mut dyn LoadingIndicator, + ) -> Result> { + match input { + InputEvent::OpenHelp => { + let popup = Self::build_mail_list_help_popup(); + self.state.popup = Some(popup); + } + InputEvent::OpenLatestPatchsets => { + if self .state .lore - .latest_patchsets - .as_ref() - .expect("invariant: init_latest_patchsets was just called") - .target_list() - .to_string(); + .mailing_list_selection + .has_valid_target_list() + { + self.init_latest_patchsets(); + let list_name = self + .state + .lore + .latest_patchsets + .as_ref() + .expect("invariant: init_latest_patchsets was just called") + .target_list() + .to_string(); - debug!(list = list_name, "fetching latest patchsets"); - loading.start(format!("Fetching patchsets from {list_name}")); - let result = app.fetch_latest_current_page().await; - loading.stop()?; - if result.is_ok() { - app.state.lore.mailing_list_selection.clear_target_list(); - app.set_current_screen(CurrentScreen::LatestPatchsets); + debug!(list = list_name, "fetching latest patchsets"); + loading.start(format!("Fetching patchsets from {list_name}")); + let result = self.fetch_latest_current_page().await; + loading.stop()?; + if result.is_ok() { + self.state.lore.mailing_list_selection.clear_target_list(); + self.set_current_screen(CurrentScreen::LatestPatchsets); + } + result?; } + } + InputEvent::RefreshMailingLists => { + debug!("refreshing available mailing lists"); + loading.start("Refreshing lists".to_string()); + let result = self.refresh_mailing_lists().await; + loading.stop()?; result?; } - } - InputEvent::RefreshMailingLists => { - debug!("refreshing available mailing lists"); - loading.start("Refreshing lists".to_string()); - let result = app.refresh_mailing_lists().await; - loading.stop()?; - result?; - } - InputEvent::OpenEditConfig => { - app.init_edit_config(); - app.set_current_screen(CurrentScreen::EditConfig); - } - InputEvent::OpenBookmarkedPatchsets => { - if !app - .state - .user_state - .bookmarked_patchsets - .bookmarked_patchsets - .is_empty() - { - app.state.lore.mailing_list_selection.clear_target_list(); - app.set_current_screen(CurrentScreen::BookmarkedPatchsets); + InputEvent::OpenEditConfig => { + self.init_edit_config(); + self.set_current_screen(CurrentScreen::EditConfig); } + InputEvent::OpenBookmarkedPatchsets => { + if !self + .state + .user_state + .bookmarked_patchsets + .bookmarked_patchsets + .is_empty() + { + self.state.lore.mailing_list_selection.clear_target_list(); + self.set_current_screen(CurrentScreen::BookmarkedPatchsets); + } + } + InputEvent::Backspace => { + self.state + .lore + .mailing_list_selection + .remove_last_target_list_char(); + } + InputEvent::Quit => { + return Ok(ControlFlow::Break(())); + } + InputEvent::TextInput(ch) => { + self.state + .lore + .mailing_list_selection + .push_char_to_target_list(ch); + } + InputEvent::NavigateDown => { + self.state + .lore + .mailing_list_selection + .highlight_below_list(); + } + InputEvent::NavigateUp => { + self.state + .lore + .mailing_list_selection + .highlight_above_list(); + } + _ => {} } - InputEvent::Backspace => { - app.state - .lore - .mailing_list_selection - .remove_last_target_list_char(); - } - InputEvent::Quit => { - return Ok(ControlFlow::Break(())); - } - InputEvent::TextInput(ch) => { - app.state - .lore - .mailing_list_selection - .push_char_to_target_list(ch); - } - InputEvent::NavigateDown => { - app.state.lore.mailing_list_selection.highlight_below_list(); - } - InputEvent::NavigateUp => { - app.state.lore.mailing_list_selection.highlight_above_list(); - } - _ => {} + Ok(ControlFlow::Continue(())) } - Ok(ControlFlow::Continue(())) -} -pub fn generate_help_popup() -> AppPopup { - AppPopup::help() + pub fn build_mail_list_help_popup() -> AppPopup { + AppPopup::help() .title("Mailing List Selection") .description("This is the mailing list selection screen.\nYou can select a mailing list by typing the name of the list.") .keybind("ESC", "Exit") @@ -108,4 +115,5 @@ pub fn generate_help_popup() -> AppPopup { .keybind("F2", "Edit config options") .keybind("F5", "Refresh lists") .build() + } } diff --git a/src/app/flows/open_patchset.rs b/src/app/flows/open_patchset.rs index 3431254..8377a1e 100644 --- a/src/app/flows/open_patchset.rs +++ b/src/app/flows/open_patchset.rs @@ -8,27 +8,28 @@ enum OpenPatchsetAction { ShowError { origin: CurrentScreen, body: String }, } -pub(super) fn apply_open_patchset_result( - app: &mut App, - origin: CurrentScreen, - result: Result, -) { - match resolve_open_patchset_result(origin, result) { - OpenPatchsetAction::ShowDetails => { - app.set_current_screen(CurrentScreen::PatchsetDetails); - } - OpenPatchsetAction::ShowError { origin, body } => { - app.state.popup = Some(AppPopup::info("Error", body)); - app.set_current_screen(origin); +impl App { + pub(super) fn apply_open_patchset_result( + &mut self, + origin: CurrentScreen, + result: Result, + ) { + match Self::resolve_open_patchset_result(origin, result) { + OpenPatchsetAction::ShowDetails => { + self.set_current_screen(CurrentScreen::PatchsetDetails); + } + OpenPatchsetAction::ShowError { origin, body } => { + self.state.popup = Some(AppPopup::info("Error", body)); + self.set_current_screen(origin); + } } } -} -fn resolve_open_patchset_result( - origin: CurrentScreen, - result: Result, -) -> OpenPatchsetAction { - match result { + fn resolve_open_patchset_result( + origin: CurrentScreen, + result: Result, + ) -> OpenPatchsetAction { + match result { Ok(B4Result::PatchFound) => OpenPatchsetAction::ShowDetails, Ok(B4Result::PatchNotFound(err_cause)) => OpenPatchsetAction::ShowError { origin, @@ -41,6 +42,7 @@ fn resolve_open_patchset_result( body: format!("The selected patchset couldn't be opened.\nReason: {error:#}"), }, } + } } #[cfg(test)] @@ -51,15 +53,17 @@ mod tests { #[test] fn patch_found_opens_details() { - let action = - resolve_open_patchset_result(CurrentScreen::LatestPatchsets, Ok(B4Result::PatchFound)); + let action = App::resolve_open_patchset_result( + CurrentScreen::LatestPatchsets, + Ok(B4Result::PatchFound), + ); assert_eq!(OpenPatchsetAction::ShowDetails, action); } #[test] fn patch_not_found_shows_retrieval_error_on_origin_screen() { - let action = resolve_open_patchset_result( + let action = App::resolve_open_patchset_result( CurrentScreen::BookmarkedPatchsets, Ok(B4Result::PatchNotFound("not found by b4".to_string())), ); @@ -75,7 +79,7 @@ mod tests { #[test] fn unexpected_error_shows_open_error_on_origin_screen() { - let action = resolve_open_patchset_result( + let action = App::resolve_open_patchset_result( CurrentScreen::LatestPatchsets, Err(eyre!("render actor unavailable")), ); diff --git a/src/app/integration_tests/flow_errors.rs b/src/app/integration_tests/flow_errors.rs index a41ac61..bef3328 100644 --- a/src/app/integration_tests/flow_errors.rs +++ b/src/app/integration_tests/flow_errors.rs @@ -1,14 +1,7 @@ use std::ops::ControlFlow; use crate::{ - app::{ - flows::{ - bookmarked::handle_bookmarked_patchsets, latest::handle_latest_patchsets, - mail_list::handle_mailing_list_selection, - }, - popup::AppPopup, - screens::CurrentScreen, - }, + app::{popup::AppPopup, screens::CurrentScreen}, input::event::InputEvent, }; @@ -27,22 +20,18 @@ async fn latest_render_failure_shows_popup_and_stays_on_latest() { ); let mut loading = FakeLoadingIndicator::default(); - let result = handle_mailing_list_selection( - &mut harness.app, - InputEvent::OpenLatestPatchsets, - &mut loading, - ) - .await - .unwrap(); + let result = harness + .app + .handle_mailing_list_selection(InputEvent::OpenLatestPatchsets, &mut loading) + .await + .unwrap(); assert_eq!(ControlFlow::Continue(()), result); - handle_latest_patchsets( - &mut harness.app, - InputEvent::OpenPatchsetDetails, - &mut loading, - ) - .await - .unwrap(); + harness + .app + .handle_latest_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) + .await + .unwrap(); assert_eq!( CurrentScreen::LatestPatchsets, @@ -67,22 +56,18 @@ async fn bookmarked_lore_failure_shows_popup_and_stays_on_bookmarks() { ); let mut loading = FakeLoadingIndicator::default(); - let result = handle_mailing_list_selection( - &mut harness.app, - InputEvent::OpenBookmarkedPatchsets, - &mut loading, - ) - .await - .unwrap(); + let result = harness + .app + .handle_mailing_list_selection(InputEvent::OpenBookmarkedPatchsets, &mut loading) + .await + .unwrap(); assert_eq!(ControlFlow::Continue(()), result); - handle_bookmarked_patchsets( - &mut harness.app, - InputEvent::OpenPatchsetDetails, - &mut loading, - ) - .await - .unwrap(); + harness + .app + .handle_bookmarked_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) + .await + .unwrap(); assert_eq!( CurrentScreen::BookmarkedPatchsets, diff --git a/src/app/integration_tests/flow_navigation.rs b/src/app/integration_tests/flow_navigation.rs index fb09436..67ff55b 100644 --- a/src/app/integration_tests/flow_navigation.rs +++ b/src/app/integration_tests/flow_navigation.rs @@ -1,15 +1,6 @@ use std::ops::ControlFlow; -use crate::{ - app::{ - flows::{ - bookmarked::handle_bookmarked_patchsets, details_actions::handle_patchset_details, - latest::handle_latest_patchsets, mail_list::handle_mailing_list_selection, - }, - screens::CurrentScreen, - }, - input::event::InputEvent, -}; +use crate::{app::screens::CurrentScreen, input::event::InputEvent}; use super::helpers::{ app_harness::{dummy_terminal_handle, AppHarness}, @@ -26,33 +17,31 @@ async fn latest_list_opens_details_and_back_returns_to_latest() { ); let mut loading = FakeLoadingIndicator::default(); - let result = handle_mailing_list_selection( - &mut harness.app, - InputEvent::OpenLatestPatchsets, - &mut loading, - ) - .await - .unwrap(); + let result = harness + .app + .handle_mailing_list_selection(InputEvent::OpenLatestPatchsets, &mut loading) + .await + .unwrap(); assert_eq!(ControlFlow::Continue(()), result); assert_eq!( CurrentScreen::LatestPatchsets, harness.app.state.navigation.current_screen ); - handle_latest_patchsets( - &mut harness.app, - InputEvent::OpenPatchsetDetails, - &mut loading, - ) - .await - .unwrap(); + harness + .app + .handle_latest_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) + .await + .unwrap(); assert_eq!( CurrentScreen::PatchsetDetails, harness.app.state.navigation.current_screen ); assert!(harness.app.state.lore.details.is_some()); - handle_patchset_details(&mut harness.app, InputEvent::Back, &dummy_terminal_handle()) + harness + .app + .handle_patchset_details(InputEvent::Back, &dummy_terminal_handle()) .await .unwrap(); @@ -71,33 +60,31 @@ async fn bookmarked_list_opens_details_and_back_returns_to_bookmarks() { ); let mut loading = FakeLoadingIndicator::default(); - let result = handle_mailing_list_selection( - &mut harness.app, - InputEvent::OpenBookmarkedPatchsets, - &mut loading, - ) - .await - .unwrap(); + let result = harness + .app + .handle_mailing_list_selection(InputEvent::OpenBookmarkedPatchsets, &mut loading) + .await + .unwrap(); assert_eq!(ControlFlow::Continue(()), result); assert_eq!( CurrentScreen::BookmarkedPatchsets, harness.app.state.navigation.current_screen ); - handle_bookmarked_patchsets( - &mut harness.app, - InputEvent::OpenPatchsetDetails, - &mut loading, - ) - .await - .unwrap(); + harness + .app + .handle_bookmarked_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) + .await + .unwrap(); assert_eq!( CurrentScreen::PatchsetDetails, harness.app.state.navigation.current_screen ); assert!(harness.app.state.lore.details.is_some()); - handle_patchset_details(&mut harness.app, InputEvent::Back, &dummy_terminal_handle()) + harness + .app + .handle_patchset_details(InputEvent::Back, &dummy_terminal_handle()) .await .unwrap(); diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index 0195f4e..ceee35f 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -1,6 +1,5 @@ use crate::{ app::{ - flows::details_actions::handle_patchset_details, popup::AppPopup, screens::{details_actions::PatchsetDetailsState, CurrentScreen}, }, @@ -19,13 +18,11 @@ async fn open_kw_ops_without_actor_stays_on_details() { harness.app.state.navigation.current_screen = CurrentScreen::PatchsetDetails; harness.app.state.lore.details = Some(details_state()); - handle_patchset_details( - &mut harness.app, - InputEvent::OpenKwOps, - &dummy_terminal_handle(), - ) - .await - .unwrap(); + harness + .app + .handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .unwrap(); assert_eq!( CurrentScreen::PatchsetDetails, @@ -71,15 +68,7 @@ mod unix { }; use crate::{ - app::{ - actor::AppActor, - flows::{ - details_actions::handle_patchset_details, - kw_ops::{fallback_kw_status, handle_kw_ops, refresh_kw_ops_log_tail}, - }, - screens::CurrentScreen, - App, - }, + app::{actor::AppActor, screens::CurrentScreen, App}, config::{ConfigSnapshot, ConfigState}, infrastructure::{ env::MockEnvTrait, @@ -121,7 +110,7 @@ mod unix { let log_dir = kw_log_dir("open-head"); let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); @@ -139,7 +128,7 @@ mod unix { async fn start_rejects_empty_branch() { let log_dir = kw_log_dir("empty-branch"); let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("")); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); assert!(app @@ -149,9 +138,7 @@ mod unix { .as_ref() .is_some_and(|ops| ops.branch.is_empty())); - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); assert_info_popup( app.state.popup.as_ref(), "Cannot start build", @@ -172,13 +159,11 @@ mod unix { process.clone(), Arc::new(MockFileSystemTrait::new()), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); assert!(app.state.popup.is_none()); let spawned = process.spawned(); assert_eq!(1, spawned.len()); @@ -192,9 +177,7 @@ mod unix { Some(KwJobStatus::Running { .. }) )); - handle_kw_ops(&mut app, InputEvent::CancelKwJob) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::CancelKwJob).await.unwrap(); assert!(app .state .kw @@ -217,13 +200,11 @@ mod unix { process.clone(), Arc::new(MockFileSystemTrait::new()), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); assert!(app.state.popup.is_none()); assert!(matches!( app.state.kw.status.as_ref().map(|s| &s.job), @@ -236,9 +217,7 @@ mod unix { .as_ref() .is_some_and(|ops| ops.start_requested)); - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); assert!(app.state.popup.is_none()); assert_eq!(1, process.spawned().len()); @@ -257,15 +236,13 @@ mod unix { process.clone(), Arc::new(MockFileSystemTrait::new()), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwBuild) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); app.state.kw.status = None; - fallback_kw_status(&mut app).await; + app.fallback_kw_status().await; assert!(matches!( app.state.kw.status.as_ref().map(|s| &s.job), Some(KwJobStatus::Running { .. }) @@ -289,7 +266,7 @@ mod unix { Arc::new(FakeProcess::new()), Arc::new(fs), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); app.state.kw.status = Some(KwStatusSnapshot { @@ -303,7 +280,7 @@ mod unix { restore_branch: None, }); - refresh_kw_ops_log_tail(&mut app).await; + app.refresh_kw_ops_log_tail().await; assert!(app .state .kw @@ -319,13 +296,13 @@ mod unix { async fn reenter_keeps_typed_extras() { let log_dir = kw_log_dir("reenter-extras"); let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); app.state.kw.ops.as_mut().unwrap().extra_args = "--verbose".to_string(); - handle_kw_ops(&mut app, InputEvent::Back).await.unwrap(); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_kw_ops(InputEvent::Back).await.unwrap(); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); @@ -340,10 +317,10 @@ mod unix { async fn back_returns_to_details() { let log_dir = kw_log_dir("back"); let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); - handle_kw_ops(&mut app, InputEvent::Back).await.unwrap(); + app.handle_kw_ops(InputEvent::Back).await.unwrap(); assert_eq!( CurrentScreen::PatchsetDetails, app.state.navigation.current_screen @@ -365,7 +342,7 @@ mod unix { deploy_kw_fs(true), default_kw_history(), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); @@ -392,13 +369,11 @@ mod unix { deploy_kw_fs(false), feature_build_history(Some(matching_feature_build_record())), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwDeploy) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StartKwDeploy).await.unwrap(); let Some(AppPopup::Confirm { title, .. }) = app.state.popup.as_ref() else { panic!("expected boot-once confirm popup"); @@ -426,13 +401,11 @@ mod unix { deploy_kw_fs(true), default_kw_history(), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwDeploy) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StartKwDeploy).await.unwrap(); assert_info_popup( app.state.popup.as_ref(), "Cannot start deploy", @@ -456,13 +429,11 @@ mod unix { deploy_kw_fs(true), feature_build_history(Some(matching_feature_build_record())), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwDeploy) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StartKwDeploy).await.unwrap(); assert!(app.state.popup.is_none()); let spawned = process.spawned(); assert_eq!(1, spawned.len()); @@ -503,11 +474,11 @@ mod unix { deploy_kw_fs(true), default_kw_history(), ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwBuildThenDeploy) + app.handle_kw_ops(InputEvent::StartKwBuildThenDeploy) .await .unwrap(); assert!(app.state.popup.is_none()); @@ -560,7 +531,7 @@ mod unix { deploy_kw_fs(true), history, ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await .unwrap(); assert!(matches!( @@ -568,13 +539,9 @@ mod unix { Err(DeployAloneRefusal::NoBuildRecord) )); - handle_kw_ops(&mut app, InputEvent::EditKwOpsField) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::EditKwOpsField).await.unwrap(); app.state.kw.ops.as_mut().unwrap().edit_buffer = "built".to_string(); - handle_kw_ops(&mut app, InputEvent::StageKwOpsEdit) - .await - .unwrap(); + app.handle_kw_ops(InputEvent::StageKwOpsEdit).await.unwrap(); let ops = app.state.kw.ops.as_ref().unwrap(); assert_eq!("built", ops.branch); From 916960c32dc20b54e44b98ba4e2c2d73bebf5884 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:14:07 -0300 Subject: [PATCH 23/61] refactor(app): move app actor helpers into AppActor The input dispatch, kw-status wait, and confirm-popup helpers are private AppActor functions. kw_status_changed, kw_ops_should_tail, on_input, and kw_job_is_running are await_kw_status_change, should_tail_kw_ops, dispatch_input, and is_kw_job_running so each name starts with a verb. No tests removed. Signed-off-by: lorenzoberts --- src/app/actor.rs | 276 +++++++++++++++++++++++++---------------------- 1 file changed, 145 insertions(+), 131 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 4d8bc42..009d342 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -105,14 +105,19 @@ impl AppActor { .map_err(terminal_error)?; } - let tail_while_running = kw_ops_should_tail(&self.app); - let poll_status = kw_status_rx.is_none() && should_poll_kw_status(&self.app); + let tail_while_running = Self::should_tail_kw_ops(&self.app); + let poll_status = kw_status_rx.is_none() && Self::should_poll_kw_status(&self.app); tokio::select! { event = self.event_rx.recv() => { match event { Some(event) => { - match on_input(&mut self.app, event, &self.terminal_handle, &mut loading) - .await? + match Self::dispatch_input( + &mut self.app, + event, + &self.terminal_handle, + &mut loading, + ) + .await? { ControlFlow::Continue(()) => { self.input_handle @@ -130,7 +135,7 @@ impl AppActor { } } } - watch_event = kw_status_changed(&mut kw_status_rx) => { + watch_event = Self::await_kw_status_change(&mut kw_status_rx) => { match watch_event { KwWatchEvent::Updated(snapshot) => { self.app.apply_kw_snapshot_refreshing_readiness(snapshot).await; @@ -190,151 +195,160 @@ impl AppActor { } } -/// Waits for the next kw-status change. With no receiver this future -/// never completes, so `select!` stays on the input arm instead of -/// spinning. -async fn kw_status_changed(rx: &mut Option>) -> KwWatchEvent { - match rx.as_mut() { - Some(rx) => match rx.changed().await { - Ok(()) => KwWatchEvent::Updated(rx.borrow_and_update().clone()), - Err(_) => KwWatchEvent::Closed, - }, - None => std::future::pending().await, +impl AppActor { + /// Waits for the next kw-status change. With no receiver this future + /// never completes, so `select!` stays on the input arm instead of + /// spinning. + async fn await_kw_status_change( + rx: &mut Option>, + ) -> KwWatchEvent { + match rx.as_mut() { + Some(rx) => match rx.changed().await { + Ok(()) => KwWatchEvent::Updated(rx.borrow_and_update().clone()), + Err(_) => KwWatchEvent::Closed, + }, + None => std::future::pending().await, + } } -} - -fn kw_ops_should_tail(app: &App) -> bool { - app.state.navigation.current_screen == CurrentScreen::KwOps - && matches!( - app.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) -} -/// When the status watch is missing, poll GetStatus while a start is -/// in flight or a job is running so `start_requested` cannot latch and -/// the nav indicator can leave "building". -fn should_poll_kw_status(app: &App) -> bool { - app.services.kw.is_some() - && (kw_job_is_running(app) - || app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.start_requested)) -} + fn should_tail_kw_ops(app: &App) -> bool { + app.state.navigation.current_screen == CurrentScreen::KwOps + && matches!( + app.state.kw.status.as_ref().map(|status| &status.job), + Some(KwJobStatus::Running { .. }) + ) + } -async fn on_input( - app: &mut App, - input: InputEvent, - terminal_handle: &TerminalHandle, - loading: &mut TerminalLoadingIndicator, -) -> Result> { - if app.state.popup.is_some() { - match input { - InputEvent::ClosePopup => { - dismiss_open_popup(app); - } - InputEvent::ConfirmPopup => { - if let Some(action) = app + /// When the status watch is missing, poll GetStatus while a start is + /// in flight or a job is running so `start_requested` cannot latch and + /// the nav indicator can leave "building". + fn should_poll_kw_status(app: &App) -> bool { + app.services.kw.is_some() + && (Self::is_kw_job_running(app) + || app .state - .popup + .kw + .ops .as_ref() - .and_then(AppPopup::selected_confirm_action) - { - return apply_confirm_action(app, action).await; + .is_some_and(|ops| ops.start_requested)) + } + + async fn dispatch_input( + app: &mut App, + input: InputEvent, + terminal_handle: &TerminalHandle, + loading: &mut TerminalLoadingIndicator, + ) -> Result> { + if app.state.popup.is_some() { + match input { + InputEvent::ClosePopup => { + Self::dismiss_open_popup(app); } - } - _ => { - if let Some(popup) = app.state.popup.as_mut() { - popup.handle_input(input); + InputEvent::ConfirmPopup => { + if let Some(action) = app + .state + .popup + .as_ref() + .and_then(AppPopup::selected_confirm_action) + { + return Self::apply_confirm_action(app, action).await; + } } - } - } - } else if input == InputEvent::Quit && kw_job_is_running(app) { - app.state.popup = Some(AppPopup::quit_while_job_running()); - } else { - tracing::debug!(screen = ?app.state.navigation.current_screen, "dispatching input to screen handler"); - match app.state.navigation.current_screen { - CurrentScreen::MailingListSelection => { - match app.handle_mailing_list_selection(input, loading).await? { - ControlFlow::Continue(()) => {} - ControlFlow::Break(()) => return Ok(ControlFlow::Break(())), + _ => { + if let Some(popup) = app.state.popup.as_mut() { + popup.handle_input(input); + } } } - CurrentScreen::BookmarkedPatchsets => { - app.handle_bookmarked_patchsets(input, loading).await?; - } - CurrentScreen::PatchsetDetails => { - app.handle_patchset_details(input, terminal_handle).await?; - } - CurrentScreen::EditConfig => { - app.handle_edit_config(input).await?; - } - CurrentScreen::LatestPatchsets => { - app.handle_latest_patchsets(input, loading).await?; - } - CurrentScreen::KwOps => { - app.handle_kw_ops(input).await?; + } else if input == InputEvent::Quit && Self::is_kw_job_running(app) { + app.state.popup = Some(AppPopup::quit_while_job_running()); + } else { + tracing::debug!(screen = ?app.state.navigation.current_screen, "dispatching input to screen handler"); + match app.state.navigation.current_screen { + CurrentScreen::MailingListSelection => { + match app.handle_mailing_list_selection(input, loading).await? { + ControlFlow::Continue(()) => {} + ControlFlow::Break(()) => return Ok(ControlFlow::Break(())), + } + } + CurrentScreen::BookmarkedPatchsets => { + app.handle_bookmarked_patchsets(input, loading).await?; + } + CurrentScreen::PatchsetDetails => { + app.handle_patchset_details(input, terminal_handle).await?; + } + CurrentScreen::EditConfig => { + app.handle_edit_config(input).await?; + } + CurrentScreen::LatestPatchsets => { + app.handle_latest_patchsets(input, loading).await?; + } + CurrentScreen::KwOps => { + app.handle_kw_ops(input).await?; + } } } + Ok(ControlFlow::Continue(())) } - Ok(ControlFlow::Continue(())) -} -fn is_boot_once_confirm(popup: &AppPopup) -> bool { - matches!( - popup.selected_confirm_action(), - Some(ConfirmAction::ProceedWithBootOnce | ConfirmAction::BackOut) - ) -} - -fn dismiss_open_popup(app: &mut App) { - if app.state.popup.as_ref().is_some_and(is_boot_once_confirm) { - app.clear_pending_deploy(); + fn is_boot_once_confirm(popup: &AppPopup) -> bool { + matches!( + popup.selected_confirm_action(), + Some(ConfirmAction::ProceedWithBootOnce | ConfirmAction::BackOut) + ) } - app.state.popup = None; -} -async fn apply_confirm_action(app: &mut App, action: ConfirmAction) -> Result> { - app.state.popup = None; - match action { - ConfirmAction::CancelKwAndQuit => Ok(cancel_kw_and_quit(app).await), - ConfirmAction::Wait => Ok(ControlFlow::Continue(())), - ConfirmAction::ProceedWithBootOnce => { - app.resume_pending_deploy().await?; - Ok(ControlFlow::Continue(())) - } - ConfirmAction::BackOut => { + fn dismiss_open_popup(app: &mut App) { + if app + .state + .popup + .as_ref() + .is_some_and(Self::is_boot_once_confirm) + { app.clear_pending_deploy(); - Ok(ControlFlow::Continue(())) } + app.state.popup = None; } -} - -fn kw_job_is_running(app: &App) -> bool { - matches!( - app.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) -} -/// Cancel then leave. A job that finished while the confirm popup was -/// open is `NoJobRunning`; that race is harmless and still quits. -async fn cancel_kw_and_quit(app: &App) -> ControlFlow<()> { - if let Some(kw) = app.services.kw.as_ref() { - match kw.cancel().await { - Ok(()) => {} - Err(KwError::NoJobRunning) => { - tracing::debug!("kw job already finished before cancel-and-quit"); + async fn apply_confirm_action(app: &mut App, action: ConfirmAction) -> Result> { + app.state.popup = None; + match action { + ConfirmAction::CancelKwAndQuit => Ok(Self::cancel_kw_and_quit(app).await), + ConfirmAction::Wait => Ok(ControlFlow::Continue(())), + ConfirmAction::ProceedWithBootOnce => { + app.resume_pending_deploy().await?; + Ok(ControlFlow::Continue(())) } - Err(error) => { - tracing::warn!(%error, "kw cancel failed while quitting"); + ConfirmAction::BackOut => { + app.clear_pending_deploy(); + Ok(ControlFlow::Continue(())) + } + } + } + + fn is_kw_job_running(app: &App) -> bool { + matches!( + app.state.kw.status.as_ref().map(|status| &status.job), + Some(KwJobStatus::Running { .. }) + ) + } + + /// Cancel then leave. A job that finished while the confirm popup was + /// open is `NoJobRunning`; that race is harmless and still quits. + async fn cancel_kw_and_quit(app: &App) -> ControlFlow<()> { + if let Some(kw) = app.services.kw.as_ref() { + match kw.cancel().await { + Ok(()) => {} + Err(KwError::NoJobRunning) => { + tracing::debug!("kw job already finished before cancel-and-quit"); + } + Err(error) => { + tracing::warn!(%error, "kw cancel failed while quitting"); + } } } + ControlFlow::Break(()) } - ControlFlow::Break(()) } #[cfg(test)] @@ -580,7 +594,7 @@ mod tests { app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); - let flow = apply_confirm_action(&mut app, ConfirmAction::ProceedWithBootOnce) + let flow = AppActor::apply_confirm_action(&mut app, ConfirmAction::ProceedWithBootOnce) .await .unwrap(); assert_eq!(ControlFlow::Continue(()), flow); @@ -605,7 +619,7 @@ mod tests { app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); - let flow = apply_confirm_action(&mut app, ConfirmAction::BackOut) + let flow = AppActor::apply_confirm_action(&mut app, ConfirmAction::BackOut) .await .unwrap(); assert_eq!(ControlFlow::Continue(()), flow); @@ -623,7 +637,7 @@ mod tests { app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); - dismiss_open_popup(&mut app); + AppActor::dismiss_open_popup(&mut app); assert!(app.state.popup.is_none()); assert_eq!(None, app.state.kw.ops.as_ref().unwrap().pending_deploy); assert!(!app.state.kw.ops.as_ref().unwrap().boot_once_acknowledged); @@ -637,7 +651,7 @@ mod tests { app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::quit_while_job_running()); - dismiss_open_popup(&mut app); + AppActor::dismiss_open_popup(&mut app); assert!(app.state.popup.is_none()); assert_eq!( Some(crate::app::screens::kw_ops::DeployStartKind::Deploy), From 375707764db793874ab235626fddd39c7dd90f2a Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:16:28 -0300 Subject: [PATCH 24/61] refactor(config): group validation and parsing into config services ConfigService, ConfigParsingService, EnvOverrideService, and ConfigPathService hold the former free functions. normalize_derived_paths is a ConfigState method. unknown_target_kernel_tree is reject_unknown_kernel_tree so the name starts with a verb. No tests removed. Signed-off-by: lorenzoberts --- src/app/flows/edit_config.rs | 4 +- src/config/actor.rs | 13 +- src/config/env_overrides.rs | 65 +++--- src/config/mod.rs | 4 +- src/config/parsing.rs | 30 +-- src/config/repository.rs | 28 +-- src/config/service.rs | 403 ++++++++++++++++++----------------- src/config/state.rs | 20 +- src/config/tests.rs | 41 ++-- src/main.rs | 5 +- 10 files changed, 317 insertions(+), 296 deletions(-) diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 26b1fb6..1c273db 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -117,7 +117,7 @@ mod tests { state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, App, AppServices, }, - config::{bootstrap_parts, ConfigActor, ConfigHandle, DEFAULT_CONFIG_PATH_SUFFIX}, + config::{ConfigActor, ConfigHandle, ConfigService, DEFAULT_CONFIG_PATH_SUFFIX}, infrastructure::{ env::MockEnvTrait, file_system::MockFileSystemTrait, file_system::OsFileSystem, shell::MockShellTrait, @@ -168,7 +168,7 @@ mod tests { fn app_with_kernel_trees(keys: &[&str], target: Option<&str>) -> (App, ConfigHandle, PathBuf) { let (env, home) = default_env(); - let (mut state, repo) = bootstrap_parts(&env, OsFileSystem).unwrap(); + let (mut state, repo) = ConfigService::bootstrap_parts(&env, OsFileSystem).unwrap(); for key in keys { state.kernel_trees.insert( (*key).to_string(), diff --git a/src/config/actor.rs b/src/config/actor.rs index 4b1ddba..55822ec 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -14,8 +14,7 @@ use crate::{ config::{ handle::ConfigHandle, messages::{ConfigMessage, ConfigResult}, - normalize_derived_paths, - service::{ensure_directories, validate_update}, + service::ConfigService, ConfigRepository, ConfigSnapshot, ConfigState, ConfigUpdateDraft, JsonConfigRepository, }, infrastructure::file_system::FileSystemTrait, @@ -83,10 +82,10 @@ where } fn apply(&mut self, draft: ConfigUpdateDraft) -> ConfigResult { - let update = validate_update(draft, self.repo.fs(), &self.state)?; + let update = ConfigService::validate_update(draft, self.repo.fs(), &self.state)?; self.state.apply_update(&update); - normalize_derived_paths(&mut self.state); - ensure_directories(&self.state, self.repo.fs())?; + self.state.normalize_derived_paths(); + ConfigService::ensure_directories(&self.state, self.repo.fs())?; self.repo.save(&self.state)?; Ok(self.state.to_snapshot()) } @@ -137,7 +136,7 @@ mod tests { use crate::{ config::{ - service::bootstrap_parts, ConfigError, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX, + service::ConfigService, ConfigError, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX, }, infrastructure::{env::MockEnvTrait, file_system::OsFileSystem}, }; @@ -180,7 +179,7 @@ mod tests { fn spawn_test_actor() -> (ConfigHandle, PathBuf) { let (env, home) = default_env(); - let (state, repo) = bootstrap_parts(&env, OsFileSystem).unwrap(); + let (state, repo) = ConfigService::bootstrap_parts(&env, OsFileSystem).unwrap(); (ConfigActor::spawn(state, repo), home) } diff --git a/src/config/env_overrides.rs b/src/config/env_overrides.rs index 3774c8b..1037b4a 100644 --- a/src/config/env_overrides.rs +++ b/src/config/env_overrides.rs @@ -1,40 +1,47 @@ use crate::config::errors::ConfigError; -use crate::config::parsing::parse_patch_renderer; +use crate::config::parsing::ConfigParsingService; use crate::config::state::ConfigState; use crate::infrastructure::env::EnvTrait; -/// Applies `PATCH_HUB_*` overrides (same semantics as legacy `Config::override_with_env_vars`). -pub fn apply_env_overrides(state: &mut ConfigState, env: &dyn EnvTrait) -> Result<(), ConfigError> { - if let Ok(page_size) = env.var("PATCH_HUB_PAGE_SIZE") { - let t = page_size.trim(); - if t.is_empty() { - return Err(ConfigError::InvalidPageSize(page_size)); +pub struct EnvOverrideService; + +impl EnvOverrideService { + /// Applies `PATCH_HUB_*` overrides (same semantics as legacy `Config::override_with_env_vars`). + pub fn apply_env_overrides( + state: &mut ConfigState, + env: &dyn EnvTrait, + ) -> Result<(), ConfigError> { + if let Ok(page_size) = env.var("PATCH_HUB_PAGE_SIZE") { + let t = page_size.trim(); + if t.is_empty() { + return Err(ConfigError::InvalidPageSize(page_size)); + } + state.page_size = t + .parse() + .map_err(|_| ConfigError::InvalidPageSize(page_size.clone()))?; } - state.page_size = t - .parse() - .map_err(|_| ConfigError::InvalidPageSize(page_size.clone()))?; - } - if let Ok(cache_dir) = env.var("PATCH_HUB_CACHE_DIR") { - state.patchsets_cache_dir = format!("{cache_dir}/patchsets"); - state.cache_dir = cache_dir; - } + if let Ok(cache_dir) = env.var("PATCH_HUB_CACHE_DIR") { + state.patchsets_cache_dir = format!("{cache_dir}/patchsets"); + state.cache_dir = cache_dir; + } - if let Ok(data_dir) = env.var("PATCH_HUB_DATA_DIR") { - state.bookmarked_patchsets_path = format!("{data_dir}/bookmarked_patchsets.json"); - state.mailing_lists_path = format!("{data_dir}/mailing_lists.json"); - state.reviewed_patchsets_path = format!("{data_dir}/reviewed_patchsets.json"); - state.logs_path = format!("{data_dir}/logs"); - state.data_dir = data_dir; - } + if let Ok(data_dir) = env.var("PATCH_HUB_DATA_DIR") { + state.bookmarked_patchsets_path = format!("{data_dir}/bookmarked_patchsets.json"); + state.mailing_lists_path = format!("{data_dir}/mailing_lists.json"); + state.reviewed_patchsets_path = format!("{data_dir}/reviewed_patchsets.json"); + state.logs_path = format!("{data_dir}/logs"); + state.data_dir = data_dir; + } - if let Ok(git_send_email_options) = env.var("PATCH_HUB_GIT_SEND_EMAIL_OPTIONS") { - state.git_send_email_options = git_send_email_options; - } + if let Ok(git_send_email_options) = env.var("PATCH_HUB_GIT_SEND_EMAIL_OPTIONS") { + state.git_send_email_options = git_send_email_options; + } - if let Ok(patch_renderer) = env.var("PATCH_HUB_PATCH_RENDERER") { - state.patch_renderer = parse_patch_renderer(&patch_renderer)?; - } + if let Ok(patch_renderer) = env.var("PATCH_HUB_PATCH_RENDERER") { + state.patch_renderer = ConfigParsingService::parse_patch_renderer(&patch_renderer)?; + } - Ok(()) + Ok(()) + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 4633c12..236ab53 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -15,8 +15,8 @@ pub use actor::ConfigActor; pub use errors::ConfigError; pub use handle::ConfigHandle; pub use repository::{ConfigRepository, JsonConfigRepository}; -pub(crate) use service::bootstrap_parts; -pub use state::{normalize_derived_paths, ConfigSnapshot, ConfigState, KernelTree}; +pub(crate) use service::ConfigService; +pub use state::{ConfigSnapshot, ConfigState, KernelTree}; pub use update::{ConfigUpdateDraft, ValidatedConfigUpdate}; pub const DEFAULT_CONFIG_PATH_SUFFIX: &str = ".config/patch-hub/config.json"; diff --git a/src/config/parsing.rs b/src/config/parsing.rs index 40da9bd..217a7d2 100644 --- a/src/config/parsing.rs +++ b/src/config/parsing.rs @@ -1,20 +1,24 @@ use crate::config::errors::ConfigError; use crate::render_prefs::{CoverRenderer, PatchRenderer}; -pub(crate) fn parse_patch_renderer(s: &str) -> Result { - match s.trim() { - "" | "default" => Ok(PatchRenderer::Default), - "bat" => Ok(PatchRenderer::Bat), - "delta" => Ok(PatchRenderer::Delta), - "diff-so-fancy" => Ok(PatchRenderer::DiffSoFancy), - other => Err(ConfigError::InvalidPatchRenderer(other.to_string())), +pub(crate) struct ConfigParsingService; + +impl ConfigParsingService { + pub(crate) fn parse_patch_renderer(s: &str) -> Result { + match s.trim() { + "" | "default" => Ok(PatchRenderer::Default), + "bat" => Ok(PatchRenderer::Bat), + "delta" => Ok(PatchRenderer::Delta), + "diff-so-fancy" => Ok(PatchRenderer::DiffSoFancy), + other => Err(ConfigError::InvalidPatchRenderer(other.to_string())), + } } -} -pub(crate) fn parse_cover_renderer(s: &str) -> Result { - match s.trim() { - "" | "default" => Ok(CoverRenderer::Default), - "bat" => Ok(CoverRenderer::Bat), - other => Err(ConfigError::InvalidCoverRenderer(other.to_string())), + pub(crate) fn parse_cover_renderer(s: &str) -> Result { + match s.trim() { + "" | "default" => Ok(CoverRenderer::Default), + "bat" => Ok(CoverRenderer::Bat), + other => Err(ConfigError::InvalidCoverRenderer(other.to_string())), + } } } diff --git a/src/config/repository.rs b/src/config/repository.rs index ae9e3e2..d87077c 100644 --- a/src/config/repository.rs +++ b/src/config/repository.rs @@ -10,17 +10,6 @@ pub trait ConfigRepository: Send + Sync { fn save(&self, state: &ConfigState) -> Result<(), ConfigError>; } -pub fn resolve_config_path(env: &dyn EnvTrait) -> String { - env.var("PATCH_HUB_CONFIG_PATH").unwrap_or_else(|_| { - format!( - "{}/{}", - env.var("HOME") - .expect("invariant: HOME environment variable must be set"), - DEFAULT_CONFIG_PATH_SUFFIX - ) - }) -} - pub struct JsonConfigRepository { config_path: String, fs: FS, @@ -29,7 +18,7 @@ pub struct JsonConfigRepository { impl JsonConfigRepository { pub fn new(env: &dyn EnvTrait, fs: FS) -> Self { Self { - config_path: resolve_config_path(env), + config_path: ConfigPathService::resolve_config_path(env), fs, } } @@ -49,3 +38,18 @@ impl ConfigRepository for JsonConfigRepository { Ok(()) } } + +pub struct ConfigPathService; + +impl ConfigPathService { + pub fn resolve_config_path(env: &dyn EnvTrait) -> String { + env.var("PATCH_HUB_CONFIG_PATH").unwrap_or_else(|_| { + format!( + "{}/{}", + env.var("HOME") + .expect("invariant: HOME environment variable must be set"), + DEFAULT_CONFIG_PATH_SUFFIX + ) + }) + } +} diff --git a/src/config/service.rs b/src/config/service.rs index 7c5ae9c..ee40a68 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -4,226 +4,229 @@ use serde_json::from_str; use crate::config::env_overrides; use crate::config::errors::ConfigError; -use crate::config::parsing::{parse_cover_renderer, parse_patch_renderer}; +use crate::config::parsing::ConfigParsingService; use crate::config::{ - normalize_derived_paths, ConfigRepository, ConfigState, ConfigUpdateDraft, - JsonConfigRepository, ValidatedConfigUpdate, + ConfigRepository, ConfigState, ConfigUpdateDraft, JsonConfigRepository, ValidatedConfigUpdate, }; use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; -/// Loads file or defaults, saves, applies env overrides, normalizes paths, and ensures directories. -pub(crate) fn bootstrap_parts( - env: &dyn EnvTrait, - fs: FS, -) -> Result<(ConfigState, JsonConfigRepository), ConfigError> { - let repo = JsonConfigRepository::new(env, fs); - let mut state = load_initial_state(env, &repo); - if let Err(e) = repo.save(&state) { - eprintln!("Failed to save default config: {e}"); +pub(crate) struct ConfigService; + +impl ConfigService { + /// Loads file or defaults, saves, applies env overrides, normalizes paths, and ensures directories. + pub(crate) fn bootstrap_parts( + env: &dyn EnvTrait, + fs: FS, + ) -> Result<(ConfigState, JsonConfigRepository), ConfigError> { + let repo = JsonConfigRepository::new(env, fs); + let mut state = Self::load_initial_state(env, &repo); + if let Err(e) = repo.save(&state) { + eprintln!("Failed to save default config: {e}"); + } + env_overrides::EnvOverrideService::apply_env_overrides(&mut state, env)?; + state.normalize_derived_paths(); + Self::ensure_directories(&state, repo.fs())?; + Ok((state, repo)) } - env_overrides::apply_env_overrides(&mut state, env)?; - normalize_derived_paths(&mut state); - ensure_directories(&state, repo.fs())?; - Ok((state, repo)) -} -fn load_initial_state( - env: &dyn EnvTrait, - repo: &JsonConfigRepository, -) -> ConfigState { - let path = repo.config_path(); - let fs = repo.fs(); - if fs.is_file(Path::new(path)) { - match fs.read_to_string(Path::new(path)) { - Ok(file_contents) => match from_str(&file_contents) { - Ok(config) => return config, - Err(e) => eprintln!("Failed to parse config file {path}: {e}"), - }, - Err(e) => { - eprintln!("Failed to read config file {path}: {e}"); + fn load_initial_state( + env: &dyn EnvTrait, + repo: &JsonConfigRepository, + ) -> ConfigState { + let path = repo.config_path(); + let fs = repo.fs(); + if fs.is_file(Path::new(path)) { + match fs.read_to_string(Path::new(path)) { + Ok(file_contents) => match from_str(&file_contents) { + Ok(config) => return config, + Err(e) => eprintln!("Failed to parse config file {path}: {e}"), + }, + Err(e) => { + eprintln!("Failed to read config file {path}: {e}"); + } } } + ConfigState::new_with_defaults(env) } - ConfigState::new_with_defaults(env) -} -pub(crate) fn ensure_directories( - state: &ConfigState, - fs: &dyn FileSystemTrait, -) -> Result<(), ConfigError> { - let paths = [ - state.cache_dir.as_str(), - state.data_dir.as_str(), - state.patchsets_cache_dir.as_str(), - state.logs_path.as_str(), - ]; - for path in paths { - if fs.metadata(Path::new(path)).is_err() { - fs.create_dir_all(Path::new(path))?; + pub(crate) fn ensure_directories( + state: &ConfigState, + fs: &dyn FileSystemTrait, + ) -> Result<(), ConfigError> { + let paths = [ + state.cache_dir.as_str(), + state.data_dir.as_str(), + state.patchsets_cache_dir.as_str(), + state.logs_path.as_str(), + ]; + for path in paths { + if fs.metadata(Path::new(path)).is_err() { + fs.create_dir_all(Path::new(path))?; + } } + Ok(()) } - Ok(()) -} -pub(crate) fn validate_update( - draft: ConfigUpdateDraft, - fs: &dyn FileSystemTrait, - current: &ConfigState, -) -> Result { - let page_size = match &draft.page_size { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidPageSize(s.clone())); - } - Some(s) => Some( - s.trim() - .parse::() - .map_err(|_| ConfigError::InvalidPageSize(s.clone()))?, - ), - }; - - let cache_dir = match &draft.cache_dir { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidDirectory( - "cache directory is empty".into(), - )); - } - Some(s) => { - let t = s.trim(); - validate_dir(fs, t)?; - Some(t.to_string()) - } - }; - - let data_dir = match &draft.data_dir { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidDirectory( - "data directory is empty".into(), - )); - } - Some(s) => { - let t = s.trim(); - validate_dir(fs, t)?; - Some(t.to_string()) - } - }; + pub(crate) fn validate_update( + draft: ConfigUpdateDraft, + fs: &dyn FileSystemTrait, + current: &ConfigState, + ) -> Result { + let page_size = match &draft.page_size { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidPageSize(s.clone())); + } + Some(s) => Some( + s.trim() + .parse::() + .map_err(|_| ConfigError::InvalidPageSize(s.clone()))?, + ), + }; + + let cache_dir = match &draft.cache_dir { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidDirectory( + "cache directory is empty".into(), + )); + } + Some(s) => { + let t = s.trim(); + Self::validate_dir(fs, t)?; + Some(t.to_string()) + } + }; + + let data_dir = match &draft.data_dir { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidDirectory( + "data directory is empty".into(), + )); + } + Some(s) => { + let t = s.trim(); + Self::validate_dir(fs, t)?; + Some(t.to_string()) + } + }; - let git_send_email_option = draft.git_send_email_option.clone(); - let git_am_option = draft.git_am_option.clone(); + let git_send_email_option = draft.git_send_email_option.clone(); + let git_am_option = draft.git_am_option.clone(); - let patch_renderer = match &draft.patch_renderer { - None => None, - Some(s) => Some(parse_patch_renderer(s)?), - }; + let patch_renderer = match &draft.patch_renderer { + None => None, + Some(s) => Some(ConfigParsingService::parse_patch_renderer(s)?), + }; - let cover_renderer = match &draft.cover_renderer { - None => None, - Some(s) => Some(parse_cover_renderer(s)?), - }; + let cover_renderer = match &draft.cover_renderer { + None => None, + Some(s) => Some(ConfigParsingService::parse_cover_renderer(s)?), + }; - let max_log_age = match &draft.max_log_age { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidMaxLogAge(s.clone())); - } - Some(s) => Some( - s.trim() - .parse::() - .map_err(|_| ConfigError::InvalidMaxLogAge(s.clone()))?, - ), - }; - - let stay_on_applied_branch = match &draft.stay_on_applied_branch { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidStayOnAppliedBranch(s.clone())); - } - Some(s) => Some( - s.trim() - .parse::() - .map_err(|_| ConfigError::InvalidStayOnAppliedBranch(s.clone()))?, - ), - }; - - let kw_reboot_after_deploy = match &draft.kw_reboot_after_deploy { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidKwRebootAfterDeploy(s.clone())); - } - Some(s) => Some( - s.trim() - .parse::() - .map_err(|_| ConfigError::InvalidKwRebootAfterDeploy(s.clone()))?, - ), - }; - - let kw_deploy_force = match &draft.kw_deploy_force { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidKwDeployForce(s.clone())); - } - Some(s) => Some( - s.trim() - .parse::() - .map_err(|_| ConfigError::InvalidKwDeployForce(s.clone()))?, - ), - }; - - let target_kernel_tree = match &draft.target_kernel_tree { - None => None, - Some(s) if s.trim().is_empty() => Some(None), - Some(s) => { - let key = s.trim(); - if current.kernel_trees.contains_key(key) { - Some(Some(key.to_string())) - } else { - return Err(unknown_target_kernel_tree(s, current)); + let max_log_age = match &draft.max_log_age { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidMaxLogAge(s.clone())); } - } - }; - - Ok(ValidatedConfigUpdate { - page_size, - cache_dir, - data_dir, - git_send_email_option, - git_am_option, - patch_renderer, - cover_renderer, - max_log_age, - stay_on_applied_branch, - kw_reboot_after_deploy, - kw_deploy_force, - target_kernel_tree, - }) -} + Some(s) => Some( + s.trim() + .parse::() + .map_err(|_| ConfigError::InvalidMaxLogAge(s.clone()))?, + ), + }; + + let stay_on_applied_branch = match &draft.stay_on_applied_branch { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidStayOnAppliedBranch(s.clone())); + } + Some(s) => Some( + s.trim() + .parse::() + .map_err(|_| ConfigError::InvalidStayOnAppliedBranch(s.clone()))?, + ), + }; + + let kw_reboot_after_deploy = match &draft.kw_reboot_after_deploy { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidKwRebootAfterDeploy(s.clone())); + } + Some(s) => Some( + s.trim() + .parse::() + .map_err(|_| ConfigError::InvalidKwRebootAfterDeploy(s.clone()))?, + ), + }; + + let kw_deploy_force = match &draft.kw_deploy_force { + None => None, + Some(s) if s.trim().is_empty() => { + return Err(ConfigError::InvalidKwDeployForce(s.clone())); + } + Some(s) => Some( + s.trim() + .parse::() + .map_err(|_| ConfigError::InvalidKwDeployForce(s.clone()))?, + ), + }; + + let target_kernel_tree = match &draft.target_kernel_tree { + None => None, + Some(s) if s.trim().is_empty() => Some(None), + Some(s) => { + let key = s.trim(); + if current.kernel_trees.contains_key(key) { + Some(Some(key.to_string())) + } else { + return Err(Self::reject_unknown_kernel_tree(s, current)); + } + } + }; + + Ok(ValidatedConfigUpdate { + page_size, + cache_dir, + data_dir, + git_send_email_option, + git_am_option, + patch_renderer, + cover_renderer, + max_log_age, + stay_on_applied_branch, + kw_reboot_after_deploy, + kw_deploy_force, + target_kernel_tree, + }) + } -fn unknown_target_kernel_tree(raw: &str, current: &ConfigState) -> ConfigError { - let mut keys: Vec = current.kernel_trees.keys().cloned().collect(); - keys.sort(); - let hint = if keys.is_empty() { - "no kernel trees are configured; unset the target or add trees in the config file" - .to_string() - } else { - format!( - "known keys: {}; unset the target or pick one of these", - keys.join(", ") - ) - }; - ConfigError::InvalidTargetKernelTree { - key: raw.to_string(), - hint, + fn reject_unknown_kernel_tree(raw: &str, current: &ConfigState) -> ConfigError { + let mut keys: Vec = current.kernel_trees.keys().cloned().collect(); + keys.sort(); + let hint = if keys.is_empty() { + "no kernel trees are configured; unset the target or add trees in the config file" + .to_string() + } else { + format!( + "known keys: {}; unset the target or pick one of these", + keys.join(", ") + ) + }; + ConfigError::InvalidTargetKernelTree { + key: raw.to_string(), + hint, + } } -} -fn validate_dir(fs: &dyn FileSystemTrait, dir_path: &str) -> Result<(), ConfigError> { - let path = Path::new(dir_path); - if fs.exists(path) && fs.is_dir(path) { - return Ok(()); + fn validate_dir(fs: &dyn FileSystemTrait, dir_path: &str) -> Result<(), ConfigError> { + let path = Path::new(dir_path); + if fs.exists(path) && fs.is_dir(path) { + return Ok(()); + } + fs.create_dir_all(path) + .map_err(|_| ConfigError::InvalidDirectory(dir_path.to_string()))?; + Ok(()) } - fs.create_dir_all(path) - .map_err(|_| ConfigError::InvalidDirectory(dir_path.to_string()))?; - Ok(()) } diff --git a/src/config/state.rs b/src/config/state.rs index 47c7cf0..764a758 100644 --- a/src/config/state.rs +++ b/src/config/state.rs @@ -186,17 +186,17 @@ impl ConfigState { pub fn to_snapshot(&self) -> ConfigSnapshot { ConfigSnapshot::from_state(self) } -} -/// Recomputes derived path fields from `cache_dir` and `data_dir`. -pub fn normalize_derived_paths(state: &mut ConfigState) { - let cache_dir = state.cache_dir.clone(); - state.patchsets_cache_dir = format!("{cache_dir}/patchsets"); - let data_dir = state.data_dir.clone(); - state.bookmarked_patchsets_path = format!("{data_dir}/bookmarked_patchsets.json"); - state.mailing_lists_path = format!("{data_dir}/mailing_lists.json"); - state.reviewed_patchsets_path = format!("{data_dir}/reviewed_patchsets.json"); - state.logs_path = format!("{data_dir}/logs"); + /// Recomputes derived path fields from `cache_dir` and `data_dir`. + pub fn normalize_derived_paths(&mut self) { + let cache_dir = self.cache_dir.clone(); + self.patchsets_cache_dir = format!("{cache_dir}/patchsets"); + let data_dir = self.data_dir.clone(); + self.bookmarked_patchsets_path = format!("{data_dir}/bookmarked_patchsets.json"); + self.mailing_lists_path = format!("{data_dir}/mailing_lists.json"); + self.reviewed_patchsets_path = format!("{data_dir}/reviewed_patchsets.json"); + self.logs_path = format!("{data_dir}/logs"); + } } /// Immutable view of configuration for the rest of the application (read-only). diff --git a/src/config/tests.rs b/src/config/tests.rs index 98fac8f..855c69c 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -9,8 +9,8 @@ use std::{ use crate::config::actor::ConfigActor; use crate::config::repository::{ConfigRepository, JsonConfigRepository}; -use crate::config::service::{bootstrap_parts, validate_update}; -use crate::config::state::{normalize_derived_paths, ConfigState}; +use crate::config::service::ConfigService; +use crate::config::state::ConfigState; use crate::config::{ ConfigError, ConfigSnapshot, ConfigUpdateDraft, KernelTree, ValidatedConfigUpdate, DEFAULT_CONFIG_PATH_SUFFIX, @@ -55,7 +55,10 @@ fn unique_test_dir(prefix: &str) -> PathBuf { } fn bootstrap_snapshot(env: &dyn EnvTrait) -> ConfigSnapshot { - bootstrap_parts(env, os_fs()).unwrap().0.to_snapshot() + ConfigService::bootstrap_parts(env, os_fs()) + .unwrap() + .0 + .to_snapshot() } /// Writable `HOME` and mock env: no `PATCH_HUB_CONFIG_PATH` (uses `HOME/.config/...`). @@ -432,7 +435,7 @@ fn bootstrap_rejects_invalid_patch_hub_page_size_env() { }) .returning(|_| Err(VarError::NotPresent.into())); - match bootstrap_parts(&mock, os_fs()) { + match ConfigService::bootstrap_parts(&mock, os_fs()) { Ok(_) => panic!("expected bootstrap to fail"), Err(err) => { assert!(matches!(err, ConfigError::InvalidPageSize(ref s) if s == "not-a-number")) @@ -468,7 +471,7 @@ fn bootstrap_rejects_invalid_patch_hub_patch_renderer_env() { .withf(|key| key == "PATCH_HUB_PATCH_RENDERER") .returning(|_| Ok("not-a-real-renderer".into())); - match bootstrap_parts(&mock, os_fs()) { + match ConfigService::bootstrap_parts(&mock, os_fs()) { Ok(_) => panic!("expected bootstrap to fail"), Err(err) => assert!(matches!( err, @@ -489,7 +492,7 @@ fn normalize_derived_paths_recomputes_cache_and_data_subpaths() { state.reviewed_patchsets_path = "stale-r".into(); state.logs_path = "stale-logs".into(); - normalize_derived_paths(&mut state); + state.normalize_derived_paths(); assert_eq!(state.patchsets_cache_dir, "/tmp/custom-cache/patchsets"); assert_eq!( @@ -509,7 +512,7 @@ fn normalize_derived_paths_recomputes_cache_and_data_subpaths() { #[test] fn validate_update_rejects_invalid_page_size() { - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { page_size: Some("xyz".into()), ..Default::default() @@ -523,7 +526,7 @@ fn validate_update_rejects_invalid_page_size() { #[test] fn validate_update_rejects_invalid_patch_renderer() { - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { patch_renderer: Some("nope".into()), ..Default::default() @@ -540,7 +543,7 @@ fn validate_update_rejects_invalid_patch_renderer() { #[test] fn validate_update_rejects_invalid_cover_renderer() { - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { cover_renderer: Some("delta".into()), ..Default::default() @@ -557,7 +560,7 @@ fn validate_update_rejects_invalid_cover_renderer() { #[test] fn validate_update_rejects_invalid_max_log_age() { - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { max_log_age: Some("not-a-number".into()), ..Default::default() @@ -575,7 +578,7 @@ fn validate_update_rejects_invalid_max_log_age() { #[test] fn validate_update_rejects_invalid_stay_on_applied_branch() { for raw in ["not-a-bool", ""] { - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { stay_on_applied_branch: Some(raw.into()), ..Default::default() @@ -616,7 +619,7 @@ fn validate_update_rejects_invalid_kw_deploy_bools() { (None, Some("not-a-bool"), false), (None, Some(""), false), ] { - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { kw_reboot_after_deploy: reboot.map(str::to_string), kw_deploy_force: force.map(str::to_string), @@ -667,7 +670,7 @@ fn apply_update_toggles_kw_deploy_knobs() { fn validate_update_accepts_existing_target_kernel_tree() { let (env, _home) = default_env(); let state = state_with_trees(&env); - let update = validate_update( + let update = ConfigService::validate_update( ConfigUpdateDraft { target_kernel_tree: Some("linux".into()), ..Default::default() @@ -683,7 +686,7 @@ fn validate_update_accepts_existing_target_kernel_tree() { fn validate_update_unsets_target_kernel_tree_on_empty_string() { let (env, _home) = default_env(); let state = state_with_trees(&env); - let update = validate_update( + let update = ConfigService::validate_update( ConfigUpdateDraft { target_kernel_tree: Some("".into()), ..Default::default() @@ -699,7 +702,7 @@ fn validate_update_unsets_target_kernel_tree_on_empty_string() { fn validate_update_rejects_unknown_target_kernel_tree() { let (env, _home) = default_env(); let state = state_with_trees(&env); - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { target_kernel_tree: Some("missing".into()), ..Default::default() @@ -745,7 +748,7 @@ fn validate_update_rejects_cache_dir_that_is_existing_file() { let root = unique_test_dir("not-a-dir"); let blocking = root.join("blocking-file"); fs::write(&blocking, b"x").unwrap(); - let err = validate_update( + let err = ConfigService::validate_update( ConfigUpdateDraft { cache_dir: Some(blocking.to_string_lossy().into_owned()), ..Default::default() @@ -792,7 +795,7 @@ fn json_config_repository_save_creates_parent_and_leaves_no_tmp_stale() { #[tokio::test] async fn validate_and_apply_persists_to_config_file() { let (env, home) = default_env(); - let (state, repo) = bootstrap_parts(&env, os_fs()).unwrap(); + let (state, repo) = ConfigService::bootstrap_parts(&env, os_fs()).unwrap(); let handle = ConfigActor::spawn(state, repo); let cfg_path = home.join(DEFAULT_CONFIG_PATH_SUFFIX); @@ -814,7 +817,7 @@ async fn validate_and_apply_persists_to_config_file() { #[tokio::test] async fn validate_and_apply_persists_target_kernel_tree() { let (env, home) = default_env(); - let (mut state, repo) = bootstrap_parts(&env, os_fs()).unwrap(); + let (mut state, repo) = ConfigService::bootstrap_parts(&env, os_fs()).unwrap(); state.kernel_trees.insert( "linux".into(), sample_kernel_tree("/home/user/linux", "master"), @@ -851,7 +854,7 @@ async fn validate_and_apply_persists_target_kernel_tree() { #[tokio::test] async fn invalid_target_kernel_tree_keeps_existing_state() { let (env, _home) = default_env(); - let (mut state, repo) = bootstrap_parts(&env, os_fs()).unwrap(); + let (mut state, repo) = ConfigService::bootstrap_parts(&env, os_fs()).unwrap(); state.kernel_trees.insert( "linux".into(), sample_kernel_tree("/home/user/linux", "master"), diff --git a/src/main.rs b/src/main.rs index 33331f9..c9a8858 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use app::{actor::AppActor, dependencies::check_external_deps, App}; use clap::Parser; use cli::Cli; use color_eyre::{eyre::eyre, Result}; -use config::{bootstrap_parts, ConfigActor}; +use config::{ConfigActor, ConfigService}; #[cfg(unix)] use infrastructure::process::OsProcess; use infrastructure::{ @@ -63,7 +63,8 @@ async fn main() -> Result<()> { infrastructure::errors::install_hooks()?; let env = OsEnv; - let (config_state, config_repo) = bootstrap_parts(&env, OsFileSystem).map_err(|e| eyre!(e))?; + let (config_state, config_repo) = + ConfigService::bootstrap_parts(&env, OsFileSystem).map_err(|e| eyre!(e))?; let config = config_state.to_snapshot(); // with the config we can update log directory From f2abb4377ca380f489d7063da6857fec00d0a54f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:18:02 -0300 Subject: [PATCH 25/61] refactor(render): group renderers into PatchRendererService and CoverRendererService The patch and cover preview helpers are associated functions on those services. bat, delta, and diff-so-fancy entry points are render_patch_with_bat, render_patch_with_delta, render_patch_with_diff_so_fancy, and render_cover_with_bat so each name starts with a verb. No tests removed. Signed-off-by: lorenzoberts --- src/render/cover_renderer.rs | 54 +++++---- src/render/mod.rs | 35 +++--- src/render/patch_renderer.rs | 222 ++++++++++++++++++----------------- 3 files changed, 164 insertions(+), 147 deletions(-) diff --git a/src/render/cover_renderer.rs b/src/render/cover_renderer.rs index fd36de3..b871b26 100644 --- a/src/render/cover_renderer.rs +++ b/src/render/cover_renderer.rs @@ -7,29 +7,37 @@ use crate::{ render_prefs::CoverRenderer, }; -pub fn render_cover(shell: &dyn ShellTrait, raw: &str, renderer: &CoverRenderer) -> Result { - let text = match renderer { - CoverRenderer::Default => Ok(raw.to_string()), - CoverRenderer::Bat => bat_cover_renderer(shell, raw), - }?; +pub struct CoverRendererService; - Ok(text) -} +impl CoverRendererService { + pub fn render_cover( + shell: &dyn ShellTrait, + raw: &str, + renderer: &CoverRenderer, + ) -> Result { + let text = match renderer { + CoverRenderer::Default => Ok(raw.to_string()), + CoverRenderer::Bat => Self::render_cover_with_bat(shell, raw), + }?; + + Ok(text) + } + + /// Renders a .mbx cover using the `bat` command line tool. + /// + /// # Errors + /// + /// If bat isn't installed or if the command fails, an error will be returned. + fn render_cover_with_bat(shell: &dyn ShellTrait, patch: &str) -> Result { + let cmd = ShellCommand::new("bat").args(["-pp", "-f", "-l", "mbx"]); + + let out = shell + .execute_with_stdin(&cmd, patch.as_bytes()) + .map_err(|e| { + event!(Level::ERROR, "Failed to spawn bat for cover preview: {}", e); + eyre!(e) + })?; -/// Renders a .mbx cover using the `bat` command line tool. -/// -/// # Errors -/// -/// If bat isn't installed or if the command fails, an error will be returned. -fn bat_cover_renderer(shell: &dyn ShellTrait, patch: &str) -> Result { - let cmd = ShellCommand::new("bat").args(["-pp", "-f", "-l", "mbx"]); - - let out = shell - .execute_with_stdin(&cmd, patch.as_bytes()) - .map_err(|e| { - event!(Level::ERROR, "Failed to spawn bat for cover preview: {}", e); - eyre!(e) - })?; - - Ok(String::from_utf8(out.stdout)?) + Ok(String::from_utf8(out.stdout)?) + } } diff --git a/src/render/mod.rs b/src/render/mod.rs index 383ea52..5938be4 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -18,7 +18,7 @@ use tracing::{event, Level}; use crate::{ infrastructure::shell::ShellTrait, lore::infrastructure::patchset_parser::split_cover, - render::{cover_renderer::render_cover, patch_renderer::render_patch_preview}, + render::{cover_renderer::CoverRendererService, patch_renderer::PatchRendererService}, }; /// [`RenderServiceApi`] backed by [`ShellTrait`] and the existing `app` renderer helpers. @@ -42,27 +42,32 @@ impl RenderServiceApi for ShellRenderService { for raw_patch in request.raw_patches { let raw_patch_expanded = raw_patch.replace('\t', " "); let (raw_cover, raw_diff) = split_cover(&raw_patch_expanded); - let rendered_cover = match render_cover(shell, raw_cover, &request.cover_renderer) { - Ok(render) => render, - Err(_) => { - event!( - Level::ERROR, - "Failed to render cover preview with external program" - ); - raw_cover.to_string() - } - }; - let rendered_patch = - match render_patch_preview(shell, raw_diff, &request.patch_renderer) { + let rendered_cover = + match CoverRendererService::render_cover(shell, raw_cover, &request.cover_renderer) + { Ok(render) => render, Err(_) => { event!( Level::ERROR, - "Failed to render patch preview with external program", + "Failed to render cover preview with external program" ); - raw_diff.to_string() + raw_cover.to_string() } }; + let rendered_patch = match PatchRendererService::render_patch_preview( + shell, + raw_diff, + &request.patch_renderer, + ) { + Ok(render) => render, + Err(_) => { + event!( + Level::ERROR, + "Failed to render patch preview with external program", + ); + raw_diff.to_string() + } + }; previews.push(RenderedPatchPreview::new(format!( "{rendered_cover}---\n{rendered_patch}" ))); diff --git a/src/render/patch_renderer.rs b/src/render/patch_renderer.rs index 62e50d1..5d89dd1 100644 --- a/src/render/patch_renderer.rs +++ b/src/render/patch_renderer.rs @@ -6,118 +6,122 @@ use crate::{ render_prefs::PatchRenderer, }; -/// Cleans patch contents before rendering for preview. Currently, it only trims -/// the trailing signature delimiter (the `--` at the end of the patch) if it -/// exists, as it is incorrectly rendered as a deletion by diff renderers. -fn clean_patch_for_preview(patch: &str) -> String { - let lines: Vec<&str> = patch.lines().collect(); - - if let Some(sig_pos) = lines.iter().position(|&line| line.trim() == "--") { - lines[..sig_pos].join("\n") - } else { - patch.to_string() +pub struct PatchRendererService; + +impl PatchRendererService { + /// Cleans patch contents before rendering for preview. Currently, it only trims + /// the trailing signature delimiter (the `--` at the end of the patch) if it + /// exists, as it is incorrectly rendered as a deletion by diff renderers. + fn clean_patch_for_preview(patch: &str) -> String { + let lines: Vec<&str> = patch.lines().collect(); + + if let Some(sig_pos) = lines.iter().position(|&line| line.trim() == "--") { + lines[..sig_pos].join("\n") + } else { + patch.to_string() + } } -} -pub fn render_patch_preview( - shell: &dyn ShellTrait, - raw: &str, - renderer: &PatchRenderer, -) -> Result { - let text = match renderer { - PatchRenderer::Default => Ok(raw.to_string()), - PatchRenderer::Bat => bat_patch_renderer(shell, raw), - PatchRenderer::Delta => delta_patch_renderer(shell, raw), - PatchRenderer::DiffSoFancy => diff_so_fancy_renderer(shell, raw), - }?; - - Ok(text) -} + pub fn render_patch_preview( + shell: &dyn ShellTrait, + raw: &str, + renderer: &PatchRenderer, + ) -> Result { + let text = match renderer { + PatchRenderer::Default => Ok(raw.to_string()), + PatchRenderer::Bat => Self::render_patch_with_bat(shell, raw), + PatchRenderer::Delta => Self::render_patch_with_delta(shell, raw), + PatchRenderer::DiffSoFancy => Self::render_patch_with_diff_so_fancy(shell, raw), + }?; + + Ok(text) + } -/// Renders a patch using the `bat` command line tool. -/// -/// # Errors -/// -/// If bat isn't installed or if the command fails, an error will be returned. -/// -/// # Tests -/// -/// [tests::test_bat_patch_renderer] -fn bat_patch_renderer(shell: &dyn ShellTrait, patch: &str) -> Result { - let cleaned_patch = clean_patch_for_preview(patch); - - let cmd = ShellCommand::new("bat").args(["-pp", "-f", "-l", "patch"]); - - let out = shell - .execute_with_stdin(&cmd, cleaned_patch.as_bytes()) - .map_err(|e| { - event!(Level::ERROR, "Failed to spawn bat for patch preview: {}", e); - eyre!(e) - })?; - - Ok(String::from_utf8(out.stdout)?) -} + /// Renders a patch using the `bat` command line tool. + /// + /// # Errors + /// + /// If bat isn't installed or if the command fails, an error will be returned. + /// + /// # Tests + /// + /// [tests::test_bat_patch_renderer] + fn render_patch_with_bat(shell: &dyn ShellTrait, patch: &str) -> Result { + let cleaned_patch = Self::clean_patch_for_preview(patch); + + let cmd = ShellCommand::new("bat").args(["-pp", "-f", "-l", "patch"]); + + let out = shell + .execute_with_stdin(&cmd, cleaned_patch.as_bytes()) + .map_err(|e| { + event!(Level::ERROR, "Failed to spawn bat for patch preview: {}", e); + eyre!(e) + })?; + + Ok(String::from_utf8(out.stdout)?) + } -/// Renders a patch using the `delta` command line tool. -/// -/// # Errors -/// -/// If delta isn't installed or if the command fails, an error will be returned. -/// -/// # Tests -/// -/// [tests::test_delta_patch_renderer] -fn delta_patch_renderer(shell: &dyn ShellTrait, patch: &str) -> Result { - let cleaned_patch = clean_patch_for_preview(patch); - - let cmd = ShellCommand::new("delta").args([ - "--pager", - "less", - "--no-gitconfig", - "--paging", - "never", - "-w", - "130", - ]); - - let out = shell - .execute_with_stdin(&cmd, cleaned_patch.as_bytes()) - .map_err(|e| { - event!( - Level::ERROR, - "Failed to spawn delta for patch preview: {}", - e - ); - eyre!(e) - })?; - - Ok(String::from_utf8(out.stdout)?) -} + /// Renders a patch using the `delta` command line tool. + /// + /// # Errors + /// + /// If delta isn't installed or if the command fails, an error will be returned. + /// + /// # Tests + /// + /// [tests::test_delta_patch_renderer] + fn render_patch_with_delta(shell: &dyn ShellTrait, patch: &str) -> Result { + let cleaned_patch = Self::clean_patch_for_preview(patch); + + let cmd = ShellCommand::new("delta").args([ + "--pager", + "less", + "--no-gitconfig", + "--paging", + "never", + "-w", + "130", + ]); + + let out = shell + .execute_with_stdin(&cmd, cleaned_patch.as_bytes()) + .map_err(|e| { + event!( + Level::ERROR, + "Failed to spawn delta for patch preview: {}", + e + ); + eyre!(e) + })?; + + Ok(String::from_utf8(out.stdout)?) + } -/// Renders a patch using the `diff-so-fancy` command line tool. -/// -/// # Errors -/// -/// If diff-so-fancy isn't installed or if the command fails, an error will be returned. -/// -/// # Tests -/// -/// [tests::test_diff_so_fancy_renderer] -fn diff_so_fancy_renderer(shell: &dyn ShellTrait, patch: &str) -> Result { - let cleaned_patch = clean_patch_for_preview(patch); - - let cmd = ShellCommand::new("diff-so-fancy"); - - let out = shell - .execute_with_stdin(&cmd, cleaned_patch.as_bytes()) - .map_err(|e| { - event!( - Level::ERROR, - "Failed to spawn diff-so-fancy for patch preview: {}", - e - ); - eyre!(e) - })?; - - Ok(String::from_utf8(out.stdout)?) + /// Renders a patch using the `diff-so-fancy` command line tool. + /// + /// # Errors + /// + /// If diff-so-fancy isn't installed or if the command fails, an error will be returned. + /// + /// # Tests + /// + /// [tests::test_diff_so_fancy_renderer] + fn render_patch_with_diff_so_fancy(shell: &dyn ShellTrait, patch: &str) -> Result { + let cleaned_patch = Self::clean_patch_for_preview(patch); + + let cmd = ShellCommand::new("diff-so-fancy"); + + let out = shell + .execute_with_stdin(&cmd, cleaned_patch.as_bytes()) + .map_err(|e| { + event!( + Level::ERROR, + "Failed to spawn diff-so-fancy for patch preview: {}", + e + ); + eyre!(e) + })?; + + Ok(String::from_utf8(out.stdout)?) + } } From 57e36d0d194bbc769827e9d0afeb3ac9cb381033 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:20:32 -0300 Subject: [PATCH 26/61] refactor(ui): group screen painting into per-screen painters Each screen module paints through a Painter. The frame layout is FramePainter and the loading overlay is LoadingPainter. Helpers that did not start with a verb are build_mode_spans, build_keys_hint_span, build_review_trailers_line, compute_log_scroll_offset, build_labeled_line, build_colored_label, build_field_line, center_rect, and advance_spinner. No tests removed. Signed-off-by: lorenzoberts --- src/terminal/session.rs | 7 +- src/ui/core.rs | 47 ++-- src/ui/loading_screen.rs | 111 ++++---- src/ui/painter.rs | 120 +++++---- src/ui/screens/bookmarked.rs | 112 ++++---- src/ui/screens/details.rs | 437 ++++++++++++++++--------------- src/ui/screens/edit_config.rs | 144 +++++----- src/ui/screens/kw_ops.rs | 371 ++++++++++++++------------ src/ui/screens/latest.rs | 112 ++++---- src/ui/screens/mailing_list.rs | 131 ++++----- src/ui/screens/navigation_bar.rs | 30 ++- src/ui/screens/popup.rs | 436 +++++++++++++++--------------- 12 files changed, 1077 insertions(+), 981 deletions(-) diff --git a/src/terminal/session.rs b/src/terminal/session.rs index db4b4d9..622319d 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -13,7 +13,7 @@ use crate::{ messages::{TerminalFrame, TerminalResult}, TerminalError, }, - ui::{loading_screen::draw_loading_screen, painter}, + ui::{loading_screen::LoadingPainter, painter::FramePainter}, }; /// Stateful terminal session owned by the terminal actor. @@ -57,10 +57,11 @@ impl TerminalSessionApi for CrosstermTerminalSession { fn draw(&mut self, frame: TerminalFrame) -> TerminalResult<()> { match frame { TerminalFrame::Main(scene) => { - self.terminal.draw(|f| painter::paint(f, &scene))?; + self.terminal.draw(|f| FramePainter::paint(f, &scene))?; } TerminalFrame::Loading(title) => { - self.terminal.draw(|f| draw_loading_screen(f, &title))?; + self.terminal + .draw(|f| LoadingPainter::draw_loading_screen(f, &title))?; } TerminalFrame::Empty => { self.terminal.draw(|_| {})?; diff --git a/src/ui/core.rs b/src/ui/core.rs index fcc83ac..7e41311 100644 --- a/src/ui/core.rs +++ b/src/ui/core.rs @@ -25,26 +25,31 @@ impl UiCore { /// Transform `vm` into a fully projected [`UiScene`] ready for painting. pub fn build_scene(&self, vm: &AppViewModel) -> Result { let body = match &vm.screen { - ScreenViewModel::MailingListSelection(mls_vm) => { - UiBody::MailingListSelection(screens::mailing_list::build_scene(mls_vm)) - } + ScreenViewModel::MailingListSelection(mls_vm) => UiBody::MailingListSelection( + screens::mailing_list::MailingListPainter::build_scene(mls_vm), + ), ScreenViewModel::Bookmarked(b_vm) => { - UiBody::Bookmarked(screens::bookmarked::build_scene(b_vm)) + UiBody::Bookmarked(screens::bookmarked::BookmarkedPainter::build_scene(b_vm)) + } + ScreenViewModel::Latest(l_vm) => { + UiBody::Latest(screens::latest::LatestPainter::build_scene(l_vm)) } - ScreenViewModel::Latest(l_vm) => UiBody::Latest(screens::latest::build_scene(l_vm)), ScreenViewModel::PatchsetDetails(pd_vm) => { - UiBody::PatchsetDetails(screens::details::build_scene(pd_vm)) + UiBody::PatchsetDetails(screens::details::DetailsPainter::build_scene(pd_vm)) } ScreenViewModel::EditConfig(ec_vm) => { - UiBody::EditConfig(screens::edit_config::build_scene(ec_vm)) + UiBody::EditConfig(screens::edit_config::EditConfigPainter::build_scene(ec_vm)) } ScreenViewModel::KwOps(kw_vm) => { - UiBody::KwOps(Box::new(screens::kw_ops::build_scene(kw_vm))) + UiBody::KwOps(Box::new(screens::kw_ops::KwOpsPainter::build_scene(kw_vm))) } }; let navigation = self.build_navigation(&vm.screen, vm.kw_running.as_deref()); - let popup = vm.popup.as_ref().map(screens::popup::build_scene); + let popup = vm + .popup + .as_ref() + .map(screens::popup::PopupPainter::build_scene); Ok(UiScene { body, @@ -60,28 +65,28 @@ impl UiCore { ) -> NavigationBarScene { let (mut mode_spans, keys_hint) = match screen { ScreenViewModel::MailingListSelection(vm) => ( - screens::mailing_list::mode_spans(vm), - screens::mailing_list::keys_hint_span(), + screens::mailing_list::MailingListPainter::build_mode_spans(vm), + screens::mailing_list::MailingListPainter::build_keys_hint_span(), ), ScreenViewModel::Bookmarked(_) => ( - screens::bookmarked::mode_spans(), - screens::bookmarked::keys_hint_span(), + screens::bookmarked::BookmarkedPainter::build_mode_spans(), + screens::bookmarked::BookmarkedPainter::build_keys_hint_span(), ), ScreenViewModel::Latest(vm) => ( - screens::latest::mode_spans(vm), - screens::latest::keys_hint_span(), + screens::latest::LatestPainter::build_mode_spans(vm), + screens::latest::LatestPainter::build_keys_hint_span(), ), ScreenViewModel::PatchsetDetails(_) => ( - screens::details::mode_spans(), - screens::details::keys_hint_span(), + screens::details::DetailsPainter::build_mode_spans(), + screens::details::DetailsPainter::build_keys_hint_span(), ), ScreenViewModel::EditConfig(vm) => ( - screens::edit_config::mode_spans(vm), - screens::edit_config::keys_hint_span(vm), + screens::edit_config::EditConfigPainter::build_mode_spans(vm), + screens::edit_config::EditConfigPainter::build_keys_hint_span(vm), ), ScreenViewModel::KwOps(vm) => ( - screens::kw_ops::mode_spans(), - screens::kw_ops::keys_hint_span(vm.editing), + screens::kw_ops::KwOpsPainter::build_mode_spans(), + screens::kw_ops::KwOpsPainter::build_keys_hint_span(vm.editing), ), }; if let Some(indicator) = kw_running { diff --git a/src/ui/loading_screen.rs b/src/ui/loading_screen.rs index 3a022b5..c15e85d 100644 --- a/src/ui/loading_screen.rs +++ b/src/ui/loading_screen.rs @@ -7,7 +7,7 @@ use ratatui::{ use std::fmt::Display; -use super::painter::centered_rect; +use super::painter::FramePainter; const SPINNER: [char; 8] = [ '\u{1F311}', @@ -24,55 +24,62 @@ static mut SPINNER_TICK: usize = 1; const LOADING_AREA_EXTRA_FACTOR_WIDTH: f32 = 1.3; const LOADING_AREA_EXTRA_LINES: u16 = 2; -/// Gets the current spinner state and updates the tick. -fn spinner() -> char { - let char_to_ret = SPINNER[unsafe { SPINNER_TICK }]; - unsafe { - SPINNER_TICK = (SPINNER_TICK + 1) % 8; - } - char_to_ret -} - -/// The actual implementation of the loading screen rendering. Currently the -/// loading notification is static. -pub(crate) fn draw_loading_screen(f: &mut Frame, title: impl Display) { - let frame_area = f.area(); - let loading_text = format!("{} {}", title, spinner()); - - let (width_pct, height_pct) = - calculate_loading_percentages(loading_text.len(), frame_area.width, frame_area.height); - - let loading_area = centered_rect(width_pct, height_pct, frame_area); +pub(crate) struct LoadingPainter; - let loading_par = Paragraph::new(Line::from(Span::styled( - loading_text, - Style::default().fg(Color::Green), - ))) - .block(Block::default().borders(Borders::ALL)) - .centered() - .wrap(Wrap { trim: true }); +impl LoadingPainter { + /// Gets the current spinner state and updates the tick. + fn advance_spinner() -> char { + let char_to_ret = SPINNER[unsafe { SPINNER_TICK }]; + unsafe { + SPINNER_TICK = (SPINNER_TICK + 1) % 8; + } + char_to_ret + } - f.render_widget(loading_par, loading_area); -} + /// The actual implementation of the loading screen rendering. Currently the + /// loading notification is static. + pub(crate) fn draw_loading_screen(f: &mut Frame, title: impl Display) { + let frame_area = f.area(); + let loading_text = format!("{} {}", title, Self::advance_spinner()); + + let (width_pct, height_pct) = Self::calculate_loading_percentages( + loading_text.len(), + frame_area.width, + frame_area.height, + ); + + let loading_area = FramePainter::center_rect(width_pct, height_pct, frame_area); + + let loading_par = Paragraph::new(Line::from(Span::styled( + loading_text, + Style::default().fg(Color::Green), + ))) + .block(Block::default().borders(Borders::ALL)) + .centered() + .wrap(Wrap { trim: true }); + + f.render_widget(loading_par, loading_area); + } -/// # Tests -/// -/// [tests::test_calculate_loading_percentages] -fn calculate_loading_percentages( - loading_text_len: usize, - frame_area_width: u16, - frame_area_height: u16, -) -> (u16, u16) { - let min_width = (loading_text_len as f32 * LOADING_AREA_EXTRA_FACTOR_WIDTH).ceil() as u16; - let min_height = { - let lines = (loading_text_len as u16).div_ceil(frame_area_width).max(1); - lines + LOADING_AREA_EXTRA_LINES - }; - - let width_pct = (100 * min_width).div_ceil(frame_area_width).min(100); - let height_pct = (100 * min_height).div_ceil(frame_area_height).min(100); - - (width_pct, height_pct) + /// # Tests + /// + /// [tests::test_calculate_loading_percentages] + fn calculate_loading_percentages( + loading_text_len: usize, + frame_area_width: u16, + frame_area_height: u16, + ) -> (u16, u16) { + let min_width = (loading_text_len as f32 * LOADING_AREA_EXTRA_FACTOR_WIDTH).ceil() as u16; + let min_height = { + let lines = (loading_text_len as u16).div_ceil(frame_area_width).max(1); + lines + LOADING_AREA_EXTRA_LINES + }; + + let width_pct = (100 * min_width).div_ceil(frame_area_width).min(100); + let height_pct = (100 * min_height).div_ceil(frame_area_height).min(100); + + (width_pct, height_pct) + } } #[cfg(test)] @@ -83,27 +90,27 @@ mod tests { /// Tests [calculate_loading_percentages] fn test_calculate_loading_percentages() { // Test case 1: standard case with reasonable frame size - let (width_pct, height_pct) = calculate_loading_percentages(10, 40, 20); + let (width_pct, height_pct) = LoadingPainter::calculate_loading_percentages(10, 40, 20); assert_eq!(width_pct, 33); // (10 * 1.3 = 13 / 40 * 100) = 32.5, rounded up to 33 assert_eq!(height_pct, 15); // min_height: 1 line + 2 extra = 3, (3 / 20 * 100) = 15 // Test case 2: text len exceeds frame width, forcing width_pct to cap at 100% - let (width_pct, height_pct) = calculate_loading_percentages(80, 40, 20); + let (width_pct, height_pct) = LoadingPainter::calculate_loading_percentages(80, 40, 20); assert_eq!(width_pct, 100); // min_width: 104 exceeds frame width, capped at 100% assert_eq!(height_pct, 20); // min_height: 2 lines + 2 extra = 4, (4 / 20 * 100) = 20 // Test case 3: Very small loading text within large frame area - let (width_pct, height_pct) = calculate_loading_percentages(5, 50, 30); + let (width_pct, height_pct) = LoadingPainter::calculate_loading_percentages(5, 50, 30); assert_eq!(width_pct, 14); // (5 * 1.3 = 7 / 50 * 100) = 14 assert_eq!(height_pct, 10); // min_height: 1 line + 2 extra = 3, (3 / 30 * 100) = 10 // Test case 4: small frame height, causing height_pct to cap at 100% - let (width_pct, height_pct) = calculate_loading_percentages(100, 40, 4); + let (width_pct, height_pct) = LoadingPainter::calculate_loading_percentages(100, 40, 4); assert_eq!(width_pct, 100); // min_width: 130 exceeds frame width, capped at 100% assert_eq!(height_pct, 100); // min_height: 3 lines + 2 extra = 5, capped at 100% // Test case 5: small frame width, but big height - let (width_pct, height_pct) = calculate_loading_percentages(100, 10, 100); + let (width_pct, height_pct) = LoadingPainter::calculate_loading_percentages(100, 10, 100); assert_eq!(width_pct, 100); // min_width: 130 exceeds frame width, capped at 100% assert_eq!(height_pct, 12); // min_height: 10 lines + 2 extra = 12 } diff --git a/src/ui/painter.rs b/src/ui/painter.rs index e0ba5de..6e39abc 100644 --- a/src/ui/painter.rs +++ b/src/ui/painter.rs @@ -17,71 +17,79 @@ use crate::ui::{ screens, }; -/// Paint `scene` onto `f`, replacing the entire frame contents. -pub fn paint(f: &mut Frame, scene: &UiScene) { - f.render_widget(Clear, f.area()); +pub struct FramePainter; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(3), - ]) - .split(f.area()); +impl FramePainter { + /// Paint `scene` onto `f`, replacing the entire frame contents. + pub fn paint(f: &mut Frame, scene: &UiScene) { + f.render_widget(Clear, f.area()); - paint_title(f, chunks[0]); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(f.area()); - match &scene.body { - UiBody::MailingListSelection(s) => screens::mailing_list::paint(f, s, chunks[1]), - UiBody::Bookmarked(s) => screens::bookmarked::paint(f, s, chunks[1]), - UiBody::Latest(s) => screens::latest::paint(f, s, chunks[1]), - UiBody::PatchsetDetails(s) => screens::details::paint(f, s, chunks[1]), - UiBody::EditConfig(s) => screens::edit_config::paint(f, s, chunks[1]), - UiBody::KwOps(s) => screens::kw_ops::paint(f, s, chunks[1]), - } + Self::paint_title(f, chunks[0]); + + match &scene.body { + UiBody::MailingListSelection(s) => { + screens::mailing_list::MailingListPainter::paint(f, s, chunks[1]) + } + UiBody::Bookmarked(s) => screens::bookmarked::BookmarkedPainter::paint(f, s, chunks[1]), + UiBody::Latest(s) => screens::latest::LatestPainter::paint(f, s, chunks[1]), + UiBody::PatchsetDetails(s) => screens::details::DetailsPainter::paint(f, s, chunks[1]), + UiBody::EditConfig(s) => { + screens::edit_config::EditConfigPainter::paint(f, s, chunks[1]) + } + UiBody::KwOps(s) => screens::kw_ops::KwOpsPainter::paint(f, s, chunks[1]), + } - screens::navigation_bar::paint(f, &scene.navigation, chunks[2]); + screens::navigation_bar::NavigationBarPainter::paint(f, &scene.navigation, chunks[2]); - if let Some(popup) = &scene.popup { - let (x, y) = popup.dimensions; - let rect = centered_rect(x, y, f.area()); - screens::popup::paint(f, popup, rect); + if let Some(popup) = &scene.popup { + let (x, y) = popup.dimensions; + let rect = Self::center_rect(x, y, f.area()); + screens::popup::PopupPainter::paint(f, popup, rect); + } } -} -fn paint_title(f: &mut Frame, chunk: Rect) { - let title_block = Block::default() - .borders(Borders::ALL) - .style(Style::default()) - .title_alignment(Alignment::Center); + fn paint_title(f: &mut Frame, chunk: Rect) { + let title_block = Block::default() + .borders(Borders::ALL) + .style(Style::default()) + .title_alignment(Alignment::Center); - let title = Paragraph::new(Text::styled( - "patch-hub", - Style::default().fg(Color::Green).bold(), - )) - .centered() - .block(title_block); + let title = Paragraph::new(Text::styled( + "patch-hub", + Style::default().fg(Color::Green).bold(), + )) + .centered() + .block(title_block); - f.render_widget(title, chunk); -} + f.render_widget(title, chunk); + } -pub(super) fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); + pub(super) fn center_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] + } } diff --git a/src/ui/screens/bookmarked.rs b/src/ui/screens/bookmarked.rs index 467002c..e87ced9 100644 --- a/src/ui/screens/bookmarked.rs +++ b/src/ui/screens/bookmarked.rs @@ -8,65 +8,73 @@ use ratatui::{ use crate::{app::view_model::BookmarkedViewModel, ui::scene::BookmarkedScene}; -pub fn build_scene(vm: &BookmarkedViewModel) -> BookmarkedScene { - BookmarkedScene { - rows: vm.rows.clone(), - selected_index: vm.selected_index, +pub struct BookmarkedPainter; + +impl BookmarkedPainter { + pub fn build_scene(vm: &BookmarkedViewModel) -> BookmarkedScene { + BookmarkedScene { + rows: vm.rows.clone(), + selected_index: vm.selected_index, + } } -} -pub fn paint(f: &mut Frame, scene: &BookmarkedScene, chunk: Rect) { - let mut list_items = Vec::::new(); + pub fn paint(f: &mut Frame, scene: &BookmarkedScene, chunk: Rect) { + let mut list_items = Vec::::new(); - for row in &scene.rows { - let patch_title = format!("{:width$}", row.title, width = 70); - let patch_title = format!("{:.width$}", patch_title, width = 70); - let patch_author = format!("{:width$}", row.author_name, width = 30); - let patch_author = format!("{:.width$}", patch_author, width = 30); - list_items.push(ListItem::new( - Line::from(Span::styled( - format!( - "{:03}. V{:02} | #{:02} | {} | {}", - row.absolute_index, row.version, row.total_in_series, patch_title, patch_author - ), - Style::default().fg(Color::Yellow), - )) - .centered(), - )); - } + for row in &scene.rows { + let patch_title = format!("{:width$}", row.title, width = 70); + let patch_title = format!("{:.width$}", patch_title, width = 70); + let patch_author = format!("{:width$}", row.author_name, width = 30); + let patch_author = format!("{:.width$}", patch_author, width = 30); + list_items.push(ListItem::new( + Line::from(Span::styled( + format!( + "{:03}. V{:02} | #{:02} | {} | {}", + row.absolute_index, + row.version, + row.total_in_series, + patch_title, + patch_author + ), + Style::default().fg(Color::Yellow), + )) + .centered(), + )); + } - let list_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); + let list_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); - let list = List::new(list_items) - .block(list_block) - .highlight_style( - Style::default() - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::REVERSED) - .fg(Color::Cyan), - ) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::Always); + let list = List::new(list_items) + .block(list_block) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + .fg(Color::Cyan), + ) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always); - let mut list_state = ListState::default(); - list_state.select(Some(scene.selected_index)); + let mut list_state = ListState::default(); + list_state.select(Some(scene.selected_index)); - f.render_stateful_widget(list, chunk, &mut list_state); -} + f.render_stateful_widget(list, chunk, &mut list_state); + } -pub fn mode_spans() -> Vec> { - vec![Span::styled( - "Bookmarked Patchsets", - Style::default().fg(Color::Green), - )] -} + pub fn build_mode_spans() -> Vec> { + vec![Span::styled( + "Bookmarked Patchsets", + Style::default().fg(Color::Green), + )] + } -pub fn keys_hint_span() -> Span<'static> { - Span::styled( - "(ESC / q) to return | (ENTER) to select | (?) help", - Style::default().fg(Color::Red), - ) + pub fn build_keys_hint_span() -> Span<'static> { + Span::styled( + "(ESC / q) to return | (ENTER) to select | (?) help", + Style::default().fg(Color::Red), + ) + } } diff --git a/src/ui/screens/details.rs b/src/ui/screens/details.rs index 5aeaa58..2b23957 100644 --- a/src/ui/screens/details.rs +++ b/src/ui/screens/details.rs @@ -10,240 +10,249 @@ use crate::{ app::view_model::PatchsetDetailsViewModel, ui::scene::{PatchsetDetailsScene, TagTrailerCounts}, }; -pub fn build_scene(vm: &PatchsetDetailsViewModel) -> PatchsetDetailsScene { - PatchsetDetailsScene { - patch_title: vm.patch_title.clone(), - author_name: vm.author_name.clone(), - version: vm.version, - patch_count: vm.patch_count, - last_updated: vm.last_updated.clone(), - tag_trailer_counts: vm.tag_trailer_counts.clone(), - staged_to_reply: vm.staged_to_reply.clone(), - preview_entries: vm.preview_entries.clone(), - preview_index: vm.preview_index, - preview_scroll_offset: vm.preview_scroll_offset, - preview_pan: vm.preview_pan, - preview_fullscreen: vm.preview_fullscreen, - preview_title: vm.preview_title.clone(), - is_bookmarked: vm.is_bookmarked, - is_apply_staged: vm.is_apply_staged, - is_current_patch_reply_staged: vm.is_current_patch_reply_staged, - } -} -pub fn paint(f: &mut Frame, scene: &PatchsetDetailsScene, chunk: Rect) { - if scene.preview_fullscreen { - paint_preview(f, scene, chunk); - } else { - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) - .split(chunk); - - let details_and_actions_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(chunks[0]); - - paint_details_and_actions( - f, - scene, - details_and_actions_chunks[0], - details_and_actions_chunks[1], - ); - paint_preview(f, scene, chunks[1]); +pub struct DetailsPainter; + +impl DetailsPainter { + pub fn build_scene(vm: &PatchsetDetailsViewModel) -> PatchsetDetailsScene { + PatchsetDetailsScene { + patch_title: vm.patch_title.clone(), + author_name: vm.author_name.clone(), + version: vm.version, + patch_count: vm.patch_count, + last_updated: vm.last_updated.clone(), + tag_trailer_counts: vm.tag_trailer_counts.clone(), + staged_to_reply: vm.staged_to_reply.clone(), + preview_entries: vm.preview_entries.clone(), + preview_index: vm.preview_index, + preview_scroll_offset: vm.preview_scroll_offset, + preview_pan: vm.preview_pan, + preview_fullscreen: vm.preview_fullscreen, + preview_title: vm.preview_title.clone(), + is_bookmarked: vm.is_bookmarked, + is_apply_staged: vm.is_apply_staged, + is_current_patch_reply_staged: vm.is_current_patch_reply_staged, + } } -} -fn review_trailers_line(counts: &TagTrailerCounts) -> Line<'static> { - let resolve_color = |n: usize| -> Style { - if n == 0 { - Style::default().fg(Color::White) + pub fn paint(f: &mut Frame, scene: &PatchsetDetailsScene, chunk: Rect) { + if scene.preview_fullscreen { + Self::paint_preview(f, scene, chunk); } else { - Style::default().fg(Color::Green) + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) + .split(chunk); + + let details_and_actions_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[0]); + + Self::paint_details_and_actions( + f, + scene, + details_and_actions_chunks[0], + details_and_actions_chunks[1], + ); + Self::paint_preview(f, scene, chunks[1]); } - }; + } - Line::from(vec![ - Span::styled("Reviewed-by: ", Style::default().fg(Color::Cyan)), - Span::styled( - counts.reviewed_by.to_string(), - resolve_color(counts.reviewed_by), - ), - Span::styled(" | Tested-by: ", Style::default().fg(Color::Cyan)), - Span::styled( - counts.tested_by.to_string(), - resolve_color(counts.tested_by), - ), - Span::styled(" | Acked-by: ", Style::default().fg(Color::Cyan)), - Span::styled(counts.acked_by.to_string(), resolve_color(counts.acked_by)), - ]) -} + fn build_review_trailers_line(counts: &TagTrailerCounts) -> Line<'static> { + let resolve_color = |n: usize| -> Style { + if n == 0 { + Style::default().fg(Color::White) + } else { + Style::default().fg(Color::Green) + } + }; -fn paint_details_and_actions( - f: &mut Frame, - scene: &PatchsetDetailsScene, - details_chunk: Rect, - actions_chunk: Rect, -) { - let mut patchset_details = vec![ - Line::from(vec![ - Span::styled(r#" Title: "#, Style::default().fg(Color::Cyan)), - Span::styled(scene.patch_title.clone(), Style::default().fg(Color::White)), - ]), Line::from(vec![ - Span::styled("Author: ", Style::default().fg(Color::Cyan)), - Span::styled(scene.author_name.clone(), Style::default().fg(Color::White)), - ]), - Line::from(vec![ - Span::styled("Version: ", Style::default().fg(Color::Cyan)), + Span::styled("Reviewed-by: ", Style::default().fg(Color::Cyan)), Span::styled( - format!("{}", scene.version), - Style::default().fg(Color::White), + counts.reviewed_by.to_string(), + resolve_color(counts.reviewed_by), ), - ]), - Line::from(vec![ - Span::styled("Patch count: ", Style::default().fg(Color::Cyan)), + Span::styled(" | Tested-by: ", Style::default().fg(Color::Cyan)), Span::styled( - format!("{}", scene.patch_count), - Style::default().fg(Color::White), + counts.tested_by.to_string(), + resolve_color(counts.tested_by), ), - ]), - Line::from(vec![ - Span::styled("Last updated: ", Style::default().fg(Color::Cyan)), - Span::styled( - scene.last_updated.clone(), - Style::default().fg(Color::White), - ), - ]), - review_trailers_line(&scene.tag_trailer_counts), - ]; - - if let Some(staged) = &scene.staged_to_reply { - patchset_details.push(Line::from(vec![ - Span::styled("Staged to reply: ", Style::default().fg(Color::Cyan)), - Span::styled(staged.clone(), Style::default().fg(Color::White)), - ])); + Span::styled(" | Acked-by: ", Style::default().fg(Color::Cyan)), + Span::styled(counts.acked_by.to_string(), resolve_color(counts.acked_by)), + ]) } - let patchset_details = Paragraph::new(patchset_details) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Double) - .title(Line::styled(" Details ", Style::default().fg(Color::Green)).left_aligned()) - .padding(Padding::vertical(1)), - ) - .left_aligned() - .wrap(Wrap { trim: true }); + fn paint_details_and_actions( + f: &mut Frame, + scene: &PatchsetDetailsScene, + details_chunk: Rect, + actions_chunk: Rect, + ) { + let mut patchset_details = vec![ + Line::from(vec![ + Span::styled(r#" Title: "#, Style::default().fg(Color::Cyan)), + Span::styled(scene.patch_title.clone(), Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled("Author: ", Style::default().fg(Color::Cyan)), + Span::styled(scene.author_name.clone(), Style::default().fg(Color::White)), + ]), + Line::from(vec![ + Span::styled("Version: ", Style::default().fg(Color::Cyan)), + Span::styled( + format!("{}", scene.version), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("Patch count: ", Style::default().fg(Color::Cyan)), + Span::styled( + format!("{}", scene.patch_count), + Style::default().fg(Color::White), + ), + ]), + Line::from(vec![ + Span::styled("Last updated: ", Style::default().fg(Color::Cyan)), + Span::styled( + scene.last_updated.clone(), + Style::default().fg(Color::White), + ), + ]), + Self::build_review_trailers_line(&scene.tag_trailer_counts), + ]; - f.render_widget(patchset_details, details_chunk); + if let Some(staged) = &scene.staged_to_reply { + patchset_details.push(Line::from(vec![ + Span::styled("Staged to reply: ", Style::default().fg(Color::Cyan)), + Span::styled(staged.clone(), Style::default().fg(Color::White)), + ])); + } - // TODO: Create a function to produce new action lines - let patchset_actions = vec![ - Line::from(vec![ - if scene.is_bookmarked { - Span::styled("[x] ", Style::default().fg(Color::Green)) - } else { - Span::styled("[ ] ", Style::default().fg(Color::Cyan)) - }, - Span::styled( - "b", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED) - .add_modifier(Modifier::BOLD), - ), - Span::styled("ookmark", Style::default().fg(Color::Cyan)), - ]), - Line::from(vec![ - if scene.is_apply_staged { - Span::styled("[x] ", Style::default().fg(Color::Green)) - } else { - Span::styled("[ ] ", Style::default().fg(Color::Cyan)) - }, - Span::styled( - "a", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED) - .add_modifier(Modifier::BOLD), - ), - Span::styled("pply", Style::default().fg(Color::Cyan)), - ]), - Line::from(vec![ - if scene.is_current_patch_reply_staged { - Span::styled("[x] ", Style::default().fg(Color::Green)) - } else { - Span::styled("[ ] ", Style::default().fg(Color::Cyan)) - }, - Span::styled( - "r", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED) - .add_modifier(Modifier::BOLD), - ), - Span::styled("eviewed-by", Style::default().fg(Color::Cyan)), - ]), - Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled( - "w", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" Kw operations", Style::default().fg(Color::Cyan)), - ]), - ]; - let patchset_actions = Paragraph::new(patchset_actions) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Double) - .title(Line::styled(" Actions ", Style::default().fg(Color::Green)).left_aligned()) - .padding(Padding::vertical(1)), - ) - .centered(); + let patchset_details = Paragraph::new(patchset_details) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .title( + Line::styled(" Details ", Style::default().fg(Color::Green)).left_aligned(), + ) + .padding(Padding::vertical(1)), + ) + .left_aligned() + .wrap(Wrap { trim: true }); - f.render_widget(patchset_actions, actions_chunk); -} + f.render_widget(patchset_details, details_chunk); -fn paint_preview(f: &mut Frame, scene: &PatchsetDetailsScene, chunk: Rect) { - let patch_preview = scene.preview_entries[scene.preview_index].clone(); - - let patch_preview = Paragraph::new(patch_preview) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Double) - .title( - Line::styled( - scene.preview_title.clone(), - Style::default().fg(Color::Green), + // TODO: Create a function to produce new action lines + let patchset_actions = vec![ + Line::from(vec![ + if scene.is_bookmarked { + Span::styled("[x] ", Style::default().fg(Color::Green)) + } else { + Span::styled("[ ] ", Style::default().fg(Color::Cyan)) + }, + Span::styled( + "b", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED) + .add_modifier(Modifier::BOLD), + ), + Span::styled("ookmark", Style::default().fg(Color::Cyan)), + ]), + Line::from(vec![ + if scene.is_apply_staged { + Span::styled("[x] ", Style::default().fg(Color::Green)) + } else { + Span::styled("[ ] ", Style::default().fg(Color::Cyan)) + }, + Span::styled( + "a", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED) + .add_modifier(Modifier::BOLD), + ), + Span::styled("pply", Style::default().fg(Color::Cyan)), + ]), + Line::from(vec![ + if scene.is_current_patch_reply_staged { + Span::styled("[x] ", Style::default().fg(Color::Green)) + } else { + Span::styled("[ ] ", Style::default().fg(Color::Cyan)) + }, + Span::styled( + "r", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED) + .add_modifier(Modifier::BOLD), + ), + Span::styled("eviewed-by", Style::default().fg(Color::Cyan)), + ]), + Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + "w", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::UNDERLINED) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" Kw operations", Style::default().fg(Color::Cyan)), + ]), + ]; + let patchset_actions = Paragraph::new(patchset_actions) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .title( + Line::styled(" Actions ", Style::default().fg(Color::Green)).left_aligned(), ) - .left_aligned(), - ) - .padding(Padding::vertical(1)), - ) - .left_aligned() - .scroll((scene.preview_scroll_offset as u16, scene.preview_pan as u16)); + .padding(Padding::vertical(1)), + ) + .centered(); - f.render_widget(patch_preview, chunk); -} + f.render_widget(patchset_actions, actions_chunk); + } -pub fn mode_spans() -> Vec> { - vec![Span::styled( - "Patchset Details and Actions", - Style::default().fg(Color::Green), - )] -} + fn paint_preview(f: &mut Frame, scene: &PatchsetDetailsScene, chunk: Rect) { + let patch_preview = scene.preview_entries[scene.preview_index].clone(); + + let patch_preview = Paragraph::new(patch_preview) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .title( + Line::styled( + scene.preview_title.clone(), + Style::default().fg(Color::Green), + ) + .left_aligned(), + ) + .padding(Padding::vertical(1)), + ) + .left_aligned() + .scroll((scene.preview_scroll_offset as u16, scene.preview_pan as u16)); + + f.render_widget(patch_preview, chunk); + } + + pub fn build_mode_spans() -> Vec> { + vec![Span::styled( + "Patchset Details and Actions", + Style::default().fg(Color::Green), + )] + } -pub fn keys_hint_span() -> Span<'static> { - Span::styled( - "(ESC / q) to return | (ENTER) run actions | (w) kw | (?) help", - Style::default().fg(Color::Red), - ) + pub fn build_keys_hint_span() -> Span<'static> { + Span::styled( + "(ESC / q) to return | (ENTER) run actions | (w) kw | (?) help", + Style::default().fg(Color::Red), + ) + } } diff --git a/src/ui/screens/edit_config.rs b/src/ui/screens/edit_config.rs index db72efd..1a04bda 100644 --- a/src/ui/screens/edit_config.rs +++ b/src/ui/screens/edit_config.rs @@ -8,85 +8,89 @@ use ratatui::{ use crate::{app::view_model::EditConfigViewModel, ui::scene::EditConfigScene}; -pub fn build_scene(vm: &EditConfigViewModel) -> EditConfigScene { - EditConfigScene { - entries: vm.entries.clone(), - } -} +pub struct EditConfigPainter; -pub fn paint(f: &mut Frame, scene: &EditConfigScene, chunk: Rect) { - let mut constraints = Vec::new(); - - for _ in 0..(chunk.height / 3) { - constraints.push(Constraint::Length(3)); +impl EditConfigPainter { + pub fn build_scene(vm: &EditConfigViewModel) -> EditConfigScene { + EditConfigScene { + entries: vm.entries.clone(), + } } - let config_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(chunk); + pub fn paint(f: &mut Frame, scene: &EditConfigScene, chunk: Rect) { + let mut constraints = Vec::new(); - for (i, entry) in scene.entries.iter().enumerate() { - if i + 1 > config_chunks.len() { - break; + for _ in 0..(chunk.height / 3) { + constraints.push(Constraint::Length(3)); } - let value = Line::from(if entry.is_editing { - vec![ - Span::styled(entry.edit_cursor_value.clone(), Style::default()), - Span::styled(" ", Style::default().bg(Color::White)), - ] - } else { - vec![Span::from(entry.value.clone())] - }); - - let config_entry = Paragraph::new(value) - .centered() - .block( - Block::default() - .borders(Borders::ALL) - .title(entry.label.clone()), - ) - .style(if entry.is_editing { - Style::default() - .fg(Color::LightYellow) - .add_modifier(Modifier::BOLD) - } else if entry.is_highlighted { - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD) + let config_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(chunk); + + for (i, entry) in scene.entries.iter().enumerate() { + if i + 1 > config_chunks.len() { + break; + } + + let value = Line::from(if entry.is_editing { + vec![ + Span::styled(entry.edit_cursor_value.clone(), Style::default()), + Span::styled(" ", Style::default().bg(Color::White)), + ] } else { - Style::default() + vec![Span::from(entry.value.clone())] }); - f.render_widget(config_entry, config_chunks[i]); + let config_entry = Paragraph::new(value) + .centered() + .block( + Block::default() + .borders(Borders::ALL) + .title(entry.label.clone()), + ) + .style(if entry.is_editing { + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD) + } else if entry.is_highlighted { + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }); + + f.render_widget(config_entry, config_chunks[i]); + } } -} -pub fn mode_spans(vm: &EditConfigViewModel) -> Vec> { - vec![if vm.is_editing_mode { - Span::styled("Editing...", Style::default().fg(Color::LightYellow)) - } else { - Span::styled("Edit Configurations", Style::default().fg(Color::Green)) - }] -} + pub fn build_mode_spans(vm: &EditConfigViewModel) -> Vec> { + vec![if vm.is_editing_mode { + Span::styled("Editing...", Style::default().fg(Color::LightYellow)) + } else { + Span::styled("Edit Configurations", Style::default().fg(Color::Green)) + }] + } -pub fn keys_hint_span(vm: &EditConfigViewModel) -> Span<'static> { - if vm.editing_tree_selector { - Span::styled( - "(←/→) cycle | (ENTER) confirm | (ESC) cancel", - Style::default().fg(Color::Red), - ) - } else if vm.is_editing_mode { - Span::styled( - "(ESC) cancel | (ENTER) confirm", - Style::default().fg(Color::Red), - ) - } else { - Span::styled( - "(ESC / q) save and exit | (ENTER) edit | (jk| 🡇 🡅 ) down up", - Style::default().fg(Color::Red), - ) + pub fn build_keys_hint_span(vm: &EditConfigViewModel) -> Span<'static> { + if vm.editing_tree_selector { + Span::styled( + "(←/→) cycle | (ENTER) confirm | (ESC) cancel", + Style::default().fg(Color::Red), + ) + } else if vm.is_editing_mode { + Span::styled( + "(ESC) cancel | (ENTER) confirm", + Style::default().fg(Color::Red), + ) + } else { + Span::styled( + "(ESC / q) save and exit | (ENTER) edit | (jk| 🡇 🡅 ) down up", + Style::default().fg(Color::Red), + ) + } } } @@ -105,21 +109,21 @@ mod tests { #[test] fn keys_hint_shows_cycle_bindings_on_the_tree_row() { - let hint = keys_hint_span(&vm(true, true)); + let hint = EditConfigPainter::build_keys_hint_span(&vm(true, true)); assert!(hint.content.contains("(←/→) cycle")); assert!(hint.content.contains("(ENTER) confirm")); } #[test] fn keys_hint_keeps_text_edit_bindings_on_other_rows() { - let hint = keys_hint_span(&vm(true, false)); + let hint = EditConfigPainter::build_keys_hint_span(&vm(true, false)); assert!(hint.content.contains("(ESC) cancel")); assert!(!hint.content.contains("cycle")); } #[test] fn keys_hint_says_save_and_exit_when_browsing() { - let hint = keys_hint_span(&vm(false, false)); + let hint = EditConfigPainter::build_keys_hint_span(&vm(false, false)); assert!(hint.content.contains("(ESC / q) save and exit")); } } diff --git a/src/ui/screens/kw_ops.rs b/src/ui/screens/kw_ops.rs index 69d3fe7..98df097 100644 --- a/src/ui/screens/kw_ops.rs +++ b/src/ui/screens/kw_ops.rs @@ -8,184 +8,200 @@ use ratatui::{ use crate::{app::view_model::KwOpsViewModel, ui::scene::KwOpsScene}; -pub fn build_scene(vm: &KwOpsViewModel) -> KwOpsScene { - KwOpsScene { - patchset_title: vm.patchset_title.clone(), - message_id: vm.message_id.clone(), - kernel_tree_id: vm.kernel_tree_id.clone(), - tree_path: vm.tree_path.clone(), - branch: vm.branch.clone(), - extra_args: vm.extra_args.clone(), - branch_focused: vm.branch_focused, - extras_focused: vm.extras_focused, - editing: vm.editing, - kw_binary: vm.kw_binary.clone(), - tree_readiness: vm.tree_readiness.clone(), - output_dir: vm.output_dir.clone(), - job_status: vm.job_status.clone(), - command: vm.command.clone(), - start_label: vm.start_label.clone(), - cancel_label: vm.cancel_label.clone(), - restore_label: vm.restore_label.clone(), - remote: vm.remote.clone(), - boot_once: vm.boot_once.clone(), - deploy_command: vm.deploy_command.clone(), - deploy_label: vm.deploy_label.clone(), - build_deploy_label: vm.build_deploy_label.clone(), - branch_guidance: vm.branch_guidance.clone(), - warnings: vm.warnings.clone(), - first_error: vm.first_error.clone(), - log_path: vm.log_path.clone(), - log_tail: vm.log_tail.clone(), +pub struct KwOpsPainter; + +impl KwOpsPainter { + pub fn build_scene(vm: &KwOpsViewModel) -> KwOpsScene { + KwOpsScene { + patchset_title: vm.patchset_title.clone(), + message_id: vm.message_id.clone(), + kernel_tree_id: vm.kernel_tree_id.clone(), + tree_path: vm.tree_path.clone(), + branch: vm.branch.clone(), + extra_args: vm.extra_args.clone(), + branch_focused: vm.branch_focused, + extras_focused: vm.extras_focused, + editing: vm.editing, + kw_binary: vm.kw_binary.clone(), + tree_readiness: vm.tree_readiness.clone(), + output_dir: vm.output_dir.clone(), + job_status: vm.job_status.clone(), + command: vm.command.clone(), + start_label: vm.start_label.clone(), + cancel_label: vm.cancel_label.clone(), + restore_label: vm.restore_label.clone(), + remote: vm.remote.clone(), + boot_once: vm.boot_once.clone(), + deploy_command: vm.deploy_command.clone(), + deploy_label: vm.deploy_label.clone(), + build_deploy_label: vm.build_deploy_label.clone(), + branch_guidance: vm.branch_guidance.clone(), + warnings: vm.warnings.clone(), + first_error: vm.first_error.clone(), + log_path: vm.log_path.clone(), + log_tail: vm.log_tail.clone(), + } } -} -pub fn paint(f: &mut Frame, scene: &KwOpsScene, chunk: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) - .split(chunk); + pub fn paint(f: &mut Frame, scene: &KwOpsScene, chunk: Rect) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(65), Constraint::Percentage(35)]) + .split(chunk); - paint_form(f, scene, chunks[0]); - paint_log(f, scene, chunks[1]); -} + Self::paint_form(f, scene, chunks[0]); + Self::paint_log(f, scene, chunks[1]); + } -fn paint_form(f: &mut Frame, scene: &KwOpsScene, chunk: Rect) { - let mut lines = vec![ - labeled("Patchset", &scene.patchset_title), - labeled("Message-ID", &scene.message_id), - labeled( - "Tree", - &format!("{} {}", scene.kernel_tree_id, scene.tree_path), - ), - field_line("Branch", &scene.branch, scene.branch_focused, scene.editing), - field_line( - "Extra args", - &scene.extra_args, - scene.extras_focused, - scene.editing, - ), - ]; - if let Some(guidance) = &scene.branch_guidance { - lines.push(Line::from(Span::styled( - guidance.clone(), - Style::default().fg(Color::Yellow), - ))); - } - lines.extend([ - labeled("kw", &scene.kw_binary), - labeled("Tree status", &scene.tree_readiness), - labeled("Output dir", &scene.output_dir), - labeled("Remote", &scene.remote), - labeled("Boot once", &scene.boot_once), - labeled("Job", &scene.job_status), - ]); - if let Some(warnings) = &scene.warnings { - lines.push(labeled_colored("Warnings", warnings, Color::Yellow)); - } - if let Some(first_error) = &scene.first_error { - lines.push(labeled_colored("First error", first_error, Color::Red)); - } - if let Some(log_path) = &scene.log_path { - lines.push(labeled("Log", log_path)); - } - lines.extend([ - labeled("Command", &scene.command), - labeled("Deploy command", &scene.deploy_command), - labeled("Start", &scene.start_label), - labeled("Deploy", &scene.deploy_label), - labeled("Build+deploy", &scene.build_deploy_label), - labeled("Cancel", &scene.cancel_label), - labeled("Restore", &scene.restore_label), - ]); - - let paragraph = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Kw operations "), - ) - .wrap(Wrap { trim: true }); - f.render_widget(paragraph, chunk); -} + fn paint_form(f: &mut Frame, scene: &KwOpsScene, chunk: Rect) { + let mut lines = vec![ + Self::build_labeled_line("Patchset", &scene.patchset_title), + Self::build_labeled_line("Message-ID", &scene.message_id), + Self::build_labeled_line( + "Tree", + &format!("{} {}", scene.kernel_tree_id, scene.tree_path), + ), + Self::build_field_line("Branch", &scene.branch, scene.branch_focused, scene.editing), + Self::build_field_line( + "Extra args", + &scene.extra_args, + scene.extras_focused, + scene.editing, + ), + ]; + if let Some(guidance) = &scene.branch_guidance { + lines.push(Line::from(Span::styled( + guidance.clone(), + Style::default().fg(Color::Yellow), + ))); + } + lines.extend([ + Self::build_labeled_line("kw", &scene.kw_binary), + Self::build_labeled_line("Tree status", &scene.tree_readiness), + Self::build_labeled_line("Output dir", &scene.output_dir), + Self::build_labeled_line("Remote", &scene.remote), + Self::build_labeled_line("Boot once", &scene.boot_once), + Self::build_labeled_line("Job", &scene.job_status), + ]); + if let Some(warnings) = &scene.warnings { + lines.push(Self::build_colored_label( + "Warnings", + warnings, + Color::Yellow, + )); + } + if let Some(first_error) = &scene.first_error { + lines.push(Self::build_colored_label( + "First error", + first_error, + Color::Red, + )); + } + if let Some(log_path) = &scene.log_path { + lines.push(Self::build_labeled_line("Log", log_path)); + } + lines.extend([ + Self::build_labeled_line("Command", &scene.command), + Self::build_labeled_line("Deploy command", &scene.deploy_command), + Self::build_labeled_line("Start", &scene.start_label), + Self::build_labeled_line("Deploy", &scene.deploy_label), + Self::build_labeled_line("Build+deploy", &scene.build_deploy_label), + Self::build_labeled_line("Cancel", &scene.cancel_label), + Self::build_labeled_line("Restore", &scene.restore_label), + ]); -fn paint_log(f: &mut Frame, scene: &KwOpsScene, chunk: Rect) { - let inner_width = chunk.width.saturating_sub(2); - let inner_height = chunk.height.saturating_sub(2); - let offset = log_scroll_offset(&scene.log_tail, inner_width, inner_height); - let paragraph = Paragraph::new(scene.log_tail.clone()) - .block(Block::default().borders(Borders::ALL).title(" Job log ")) - .wrap(Wrap { trim: false }) - .scroll((offset, 0)); - f.render_widget(paragraph, chunk); -} + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Kw operations "), + ) + .wrap(Wrap { trim: true }); + f.render_widget(paragraph, chunk); + } -/// Scroll so the newest wrapped rows sit at the bottom of the pane. -/// -/// Uses ratatui's wrap-aware [`Paragraph::line_count`] so the offset -/// matches what the painter actually renders (word wrap, tabs, wide -/// chars). Counted without a [`Block`] and with the inner width, so -/// border rows are not mixed into the text height. -pub(crate) fn log_scroll_offset(text: &str, inner_width: u16, inner_height: u16) -> u16 { - if inner_height == 0 || inner_width == 0 { - return 0; - } - let rows = Paragraph::new(text) - .wrap(Wrap { trim: false }) - .line_count(inner_width); - rows.saturating_sub(inner_height as usize) as u16 -} + fn paint_log(f: &mut Frame, scene: &KwOpsScene, chunk: Rect) { + let inner_width = chunk.width.saturating_sub(2); + let inner_height = chunk.height.saturating_sub(2); + let offset = Self::compute_log_scroll_offset(&scene.log_tail, inner_width, inner_height); + let paragraph = Paragraph::new(scene.log_tail.clone()) + .block(Block::default().borders(Borders::ALL).title(" Job log ")) + .wrap(Wrap { trim: false }) + .scroll((offset, 0)); + f.render_widget(paragraph, chunk); + } -fn labeled(label: &str, value: &str) -> Line<'static> { - labeled_colored(label, value, Color::White) -} + /// Scroll so the newest wrapped rows sit at the bottom of the pane. + /// + /// Uses ratatui's wrap-aware [`Paragraph::line_count`] so the offset + /// matches what the painter actually renders (word wrap, tabs, wide + /// chars). Counted without a [`Block`] and with the inner width, so + /// border rows are not mixed into the text height. + pub(crate) fn compute_log_scroll_offset( + text: &str, + inner_width: u16, + inner_height: u16, + ) -> u16 { + if inner_height == 0 || inner_width == 0 { + return 0; + } + let rows = Paragraph::new(text) + .wrap(Wrap { trim: false }) + .line_count(inner_width); + rows.saturating_sub(inner_height as usize) as u16 + } -fn labeled_colored(label: &str, value: &str, color: Color) -> Line<'static> { - Line::from(vec![ - Span::styled(format!("{label}: "), Style::default().fg(Color::Cyan)), - Span::styled(value.to_string(), Style::default().fg(color)), - ]) -} + fn build_labeled_line(label: &str, value: &str) -> Line<'static> { + Self::build_colored_label(label, value, Color::White) + } -fn field_line(label: &str, value: &str, focused: bool, editing: bool) -> Line<'static> { - let display = if value.is_empty() { " " } else { value }; - let mut style = Style::default().fg(Color::White); - if focused { - style = style.add_modifier(Modifier::BOLD); - if editing { - style = style.fg(Color::LightYellow); - } else { - style = style.fg(Color::Yellow); - } + fn build_colored_label(label: &str, value: &str, color: Color) -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{label}: "), Style::default().fg(Color::Cyan)), + Span::styled(value.to_string(), Style::default().fg(color)), + ]) } - let mut spans = vec![ - Span::styled(format!("{label}: "), Style::default().fg(Color::Cyan)), - Span::styled(display.to_string(), style), - ]; - if focused && editing { - spans.push(Span::styled(" ", Style::default().bg(Color::White))); + + fn build_field_line(label: &str, value: &str, focused: bool, editing: bool) -> Line<'static> { + let display = if value.is_empty() { " " } else { value }; + let mut style = Style::default().fg(Color::White); + if focused { + style = style.add_modifier(Modifier::BOLD); + if editing { + style = style.fg(Color::LightYellow); + } else { + style = style.fg(Color::Yellow); + } + } + let mut spans = vec![ + Span::styled(format!("{label}: "), Style::default().fg(Color::Cyan)), + Span::styled(display.to_string(), style), + ]; + if focused && editing { + spans.push(Span::styled(" ", Style::default().bg(Color::White))); + } + Line::from(spans) } - Line::from(spans) -} -pub fn mode_spans() -> Vec> { - vec![Span::styled( - "Kw operations", - Style::default().fg(Color::Green), - )] -} + pub fn build_mode_spans() -> Vec> { + vec![Span::styled( + "Kw operations", + Style::default().fg(Color::Green), + )] + } -pub fn keys_hint_span(editing: bool) -> Span<'static> { - if editing { - Span::styled( - "(ESC) cancel edit | (ENTER) confirm", - Style::default().fg(Color::Red), - ) - } else { - Span::styled( + pub fn build_keys_hint_span(editing: bool) -> Span<'static> { + if editing { + Span::styled( + "(ESC) cancel edit | (ENTER) confirm", + Style::default().fg(Color::Red), + ) + } else { + Span::styled( "(ESC / q) back | (e) edit | (b) build | (d) deploy | (D) build+deploy | (c) cancel | (r) restore | (?) help", Style::default().fg(Color::Red), ) + } } } @@ -195,7 +211,10 @@ mod tests { #[test] fn short_log_does_not_scroll() { - assert_eq!(0, log_scroll_offset("cc1: compiling\n", 40, 10)); + assert_eq!( + 0, + KwOpsPainter::compute_log_scroll_offset("cc1: compiling\n", 40, 10) + ); } #[test] @@ -204,37 +223,49 @@ mod tests { .map(|i| format!("line {i}")) .collect::>() .join("\n"); - assert_eq!(15, log_scroll_offset(&text, 40, 5)); + assert_eq!(15, KwOpsPainter::compute_log_scroll_offset(&text, 40, 5)); } #[test] fn hard_wrapped_lines_count_toward_the_offset() { - assert_eq!(1, log_scroll_offset("abcdefghij", 4, 2)); + assert_eq!( + 1, + KwOpsPainter::compute_log_scroll_offset("abcdefghij", 4, 2) + ); } #[test] fn word_wrapped_lines_pin_the_newest_rows() { // Width 10, "aaaaa aaaaa aaaaa": char-ceil would be 2 rows; ratatui // word-wraps to 3, so height 2 must scroll by 1 to keep the end visible. - assert_eq!(1, log_scroll_offset("aaaaa aaaaa aaaaa", 10, 2)); + assert_eq!( + 1, + KwOpsPainter::compute_log_scroll_offset("aaaaa aaaaa aaaaa", 10, 2) + ); } #[test] fn trailing_newline_does_not_invent_an_extra_row() { // ratatui's line_count does not treat a trailing newline as a // blank wrapped row, so a one-line pane shows "a" with no scroll. - assert_eq!(0, log_scroll_offset("a\n", 10, 1)); + assert_eq!(0, KwOpsPainter::compute_log_scroll_offset("a\n", 10, 1)); } #[test] fn zero_inner_area_does_not_scroll() { - assert_eq!(0, log_scroll_offset("line\nline\n", 0, 10)); - assert_eq!(0, log_scroll_offset("line\nline\n", 10, 0)); + assert_eq!( + 0, + KwOpsPainter::compute_log_scroll_offset("line\nline\n", 0, 10) + ); + assert_eq!( + 0, + KwOpsPainter::compute_log_scroll_offset("line\nline\n", 10, 0) + ); } #[test] fn keys_hint_lists_deploy_bindings() { - let hint = keys_hint_span(false); + let hint = KwOpsPainter::build_keys_hint_span(false); assert!(hint.content.contains("(d) deploy")); assert!(hint.content.contains("(D) build+deploy")); } diff --git a/src/ui/screens/latest.rs b/src/ui/screens/latest.rs index 0b1dc46..35183f0 100644 --- a/src/ui/screens/latest.rs +++ b/src/ui/screens/latest.rs @@ -8,68 +8,76 @@ use ratatui::{ use crate::{app::view_model::LatestPatchsetsViewModel, ui::scene::LatestScene}; -pub fn build_scene(vm: &LatestPatchsetsViewModel) -> LatestScene { - LatestScene { - rows: vm.rows.clone(), - selected_index: vm.selected_index, +pub struct LatestPainter; + +impl LatestPainter { + pub fn build_scene(vm: &LatestPatchsetsViewModel) -> LatestScene { + LatestScene { + rows: vm.rows.clone(), + selected_index: vm.selected_index, + } } -} -pub fn paint(f: &mut Frame, scene: &LatestScene, chunk: Rect) { - let mut list_items = Vec::::new(); + pub fn paint(f: &mut Frame, scene: &LatestScene, chunk: Rect) { + let mut list_items = Vec::::new(); - for row in &scene.rows { - let patch_title = format!("{:width$}", row.title, width = 70); - let patch_title = format!("{:.width$}", patch_title, width = 70); - let patch_author = format!("{:width$}", row.author_name, width = 30); - let patch_author = format!("{:.width$}", patch_author, width = 30); - list_items.push(ListItem::new( - Line::from(Span::styled( - format!( - "{:03}. V{:02} | #{:02} | {} | {}", - row.absolute_index, row.version, row.total_in_series, patch_title, patch_author - ), - Style::default().fg(Color::Yellow), - )) - .centered(), - )); - } + for row in &scene.rows { + let patch_title = format!("{:width$}", row.title, width = 70); + let patch_title = format!("{:.width$}", patch_title, width = 70); + let patch_author = format!("{:width$}", row.author_name, width = 30); + let patch_author = format!("{:.width$}", patch_author, width = 30); + list_items.push(ListItem::new( + Line::from(Span::styled( + format!( + "{:03}. V{:02} | #{:02} | {} | {}", + row.absolute_index, + row.version, + row.total_in_series, + patch_title, + patch_author + ), + Style::default().fg(Color::Yellow), + )) + .centered(), + )); + } - let list_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); + let list_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); - let list = List::new(list_items) - .block(list_block) - .highlight_style( - Style::default() - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::REVERSED) - .fg(Color::Cyan), - ) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::Always); + let list = List::new(list_items) + .block(list_block) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + .fg(Color::Cyan), + ) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always); - let mut list_state = ListState::default(); - list_state.select(Some(scene.selected_index)); + let mut list_state = ListState::default(); + list_state.select(Some(scene.selected_index)); - f.render_stateful_widget(list, chunk, &mut list_state); -} + f.render_stateful_widget(list, chunk, &mut list_state); + } -pub fn mode_spans(vm: &LatestPatchsetsViewModel) -> Vec> { - vec![Span::styled( - format!( - "Latest Patchsets from {} (page {})", - vm.target_list, vm.page_number - ), - Style::default().fg(Color::Green), - )] -} + pub fn build_mode_spans(vm: &LatestPatchsetsViewModel) -> Vec> { + vec![Span::styled( + format!( + "Latest Patchsets from {} (page {})", + vm.target_list, vm.page_number + ), + Style::default().fg(Color::Green), + )] + } -pub fn keys_hint_span() -> Span<'static> { - Span::styled( + pub fn build_keys_hint_span() -> Span<'static> { + Span::styled( "(ESC / q) to return | (ENTER) to select | ( h / 🡄 ) previous page | ( l / 🡆 ) next page | (?) help", Style::default().fg(Color::Red), ) + } } diff --git a/src/ui/screens/mailing_list.rs b/src/ui/screens/mailing_list.rs index 868cf44..74f6f77 100644 --- a/src/ui/screens/mailing_list.rs +++ b/src/ui/screens/mailing_list.rs @@ -10,77 +10,82 @@ use crate::{ app::view_model::{MailingListSelectionViewModel, TargetListStatus}, ui::scene::MailingListScene, }; -pub fn build_scene(vm: &MailingListSelectionViewModel) -> MailingListScene { - MailingListScene { - entries: vm.entries.clone(), - highlighted_index: vm.highlighted_index, - } -} -pub fn paint(f: &mut Frame, scene: &MailingListScene, chunk: Rect) { - let mut list_items = Vec::::new(); +pub struct MailingListPainter; - for entry in &scene.entries { - list_items.push(ListItem::new( - Line::from(vec![ - Span::styled(entry.name.clone(), Style::default().fg(Color::Magenta)), - Span::styled( - format!(" - {}", entry.description), - Style::default().fg(Color::White), - ), - ]) - .centered(), - )); +impl MailingListPainter { + pub fn build_scene(vm: &MailingListSelectionViewModel) -> MailingListScene { + MailingListScene { + entries: vm.entries.clone(), + highlighted_index: vm.highlighted_index, + } } - let list_block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); + pub fn paint(f: &mut Frame, scene: &MailingListScene, chunk: Rect) { + let mut list_items = Vec::::new(); - let list = List::new(list_items) - .block(list_block) - .highlight_style( - Style::default() - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::REVERSED) - .fg(Color::Cyan), - ) - .highlight_symbol(">") - .highlight_spacing(HighlightSpacing::Always); + for entry in &scene.entries { + list_items.push(ListItem::new( + Line::from(vec![ + Span::styled(entry.name.clone(), Style::default().fg(Color::Magenta)), + Span::styled( + format!(" - {}", entry.description), + Style::default().fg(Color::White), + ), + ]) + .centered(), + )); + } - let mut list_state = ListState::default(); - list_state.select(Some(scene.highlighted_index)); + let list_block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); - f.render_stateful_widget(list, chunk, &mut list_state); -} + let list = List::new(list_items) + .block(list_block) + .highlight_style( + Style::default() + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + .fg(Color::Cyan), + ) + .highlight_symbol(">") + .highlight_spacing(HighlightSpacing::Always); -pub fn mode_spans(vm: &MailingListSelectionViewModel) -> Vec> { - let text_area = match vm.target_list_status { - TargetListStatus::Empty => { - Span::styled("type the target list", Style::default().fg(Color::DarkGray)) - } - TargetListStatus::ExactMatch => { - Span::styled(vm.target_list.clone(), Style::default().fg(Color::Green)) - } - TargetListStatus::PrefixMatch => Span::styled( - vm.target_list.clone(), - Style::default().fg(Color::LightCyan), - ), - TargetListStatus::NoMatch => { - Span::styled(vm.target_list.clone(), Style::default().fg(Color::Red)) - } - }; + let mut list_state = ListState::default(); + list_state.select(Some(scene.highlighted_index)); - vec![ - Span::styled("Target List: ", Style::default().fg(Color::Green)), - text_area, - ] -} + f.render_stateful_widget(list, chunk, &mut list_state); + } + + pub fn build_mode_spans(vm: &MailingListSelectionViewModel) -> Vec> { + let text_area = match vm.target_list_status { + TargetListStatus::Empty => { + Span::styled("type the target list", Style::default().fg(Color::DarkGray)) + } + TargetListStatus::ExactMatch => { + Span::styled(vm.target_list.clone(), Style::default().fg(Color::Green)) + } + TargetListStatus::PrefixMatch => Span::styled( + vm.target_list.clone(), + Style::default().fg(Color::LightCyan), + ), + TargetListStatus::NoMatch => { + Span::styled(vm.target_list.clone(), Style::default().fg(Color::Red)) + } + }; -pub fn keys_hint_span() -> Span<'static> { - Span::styled( - "(ESC) to quit | (ENTER) to confirm | (?) help", - Style::default().fg(Color::Red), - ) + vec![ + Span::styled("Target List: ", Style::default().fg(Color::Green)), + text_area, + ] + } + + pub fn build_keys_hint_span() -> Span<'static> { + Span::styled( + "(ESC) to quit | (ENTER) to confirm | (?) help", + Style::default().fg(Color::Red), + ) + } } diff --git a/src/ui/screens/navigation_bar.rs b/src/ui/screens/navigation_bar.rs index 40b291b..e23c49f 100644 --- a/src/ui/screens/navigation_bar.rs +++ b/src/ui/screens/navigation_bar.rs @@ -7,20 +7,24 @@ use ratatui::{ use crate::ui::scene::NavigationBarScene; -pub fn paint(f: &mut Frame, scene: &NavigationBarScene, chunk: Rect) { - let mode_footer = Paragraph::new(Line::from(scene.mode_spans.clone())) - .block(Block::default().borders(Borders::ALL)) - .centered(); +pub struct NavigationBarPainter; - let keys_hint_footer = Paragraph::new(Line::from(scene.keys_hint.clone())) - .block(Block::default().borders(Borders::ALL)) - .centered(); +impl NavigationBarPainter { + pub fn paint(f: &mut Frame, scene: &NavigationBarScene, chunk: Rect) { + let mode_footer = Paragraph::new(Line::from(scene.mode_spans.clone())) + .block(Block::default().borders(Borders::ALL)) + .centered(); - let footer_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(30), Constraint::Percentage(80)]) - .split(chunk); + let keys_hint_footer = Paragraph::new(Line::from(scene.keys_hint.clone())) + .block(Block::default().borders(Borders::ALL)) + .centered(); - f.render_widget(mode_footer, footer_chunks[0]); - f.render_widget(keys_hint_footer, footer_chunks[1]); + let footer_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(30), Constraint::Percentage(80)]) + .split(chunk); + + f.render_widget(mode_footer, footer_chunks[0]); + f.render_widget(keys_hint_footer, footer_chunks[1]); + } } diff --git a/src/ui/screens/popup.rs b/src/ui/screens/popup.rs index 08fed1c..367e4ec 100644 --- a/src/ui/screens/popup.rs +++ b/src/ui/screens/popup.rs @@ -11,232 +11,238 @@ use crate::{ ui::scene::{PopupBody, PopupScene}, }; -pub fn build_scene(vm: &PopupViewModel) -> PopupScene { - let body = match &vm.body { - PopupViewBody::Text(text) => PopupBody::Text(text.clone()), - PopupViewBody::Keybinds { - description, - formatted_keybinds, - } => PopupBody::Keybinds { - description: description.clone(), - formatted_keybinds: formatted_keybinds.clone(), - }, - PopupViewBody::ReviewTrailers { - reviewed_by, - tested_by, - acked_by, - } => PopupBody::ReviewTrailers { - reviewed_by: reviewed_by.clone(), - tested_by: tested_by.clone(), - acked_by: acked_by.clone(), - }, - PopupViewBody::Confirm { +pub struct PopupPainter; + +impl PopupPainter { + pub fn build_scene(vm: &PopupViewModel) -> PopupScene { + let body = match &vm.body { + PopupViewBody::Text(text) => PopupBody::Text(text.clone()), + PopupViewBody::Keybinds { + description, + formatted_keybinds, + } => PopupBody::Keybinds { + description: description.clone(), + formatted_keybinds: formatted_keybinds.clone(), + }, + PopupViewBody::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + } => PopupBody::ReviewTrailers { + reviewed_by: reviewed_by.clone(), + tested_by: tested_by.clone(), + acked_by: acked_by.clone(), + }, + PopupViewBody::Confirm { + body, + options, + selected, + } => PopupBody::Confirm { + body: body.clone(), + options: options.clone(), + selected: *selected, + }, + }; + + PopupScene { + title: vm.title.clone(), body, - options, - selected, - } => PopupBody::Confirm { - body: body.clone(), - options: options.clone(), - selected: *selected, - }, - }; - - PopupScene { - title: vm.title.clone(), - body, - scroll_offset: vm.scroll_offset, - dimensions: vm.dimensions, + scroll_offset: vm.scroll_offset, + dimensions: vm.dimensions, + } } -} -pub fn paint(f: &mut Frame, scene: &PopupScene, chunk: Rect) { - match &scene.body { - PopupBody::Text(body) => paint_info(f, &scene.title, body, scene.scroll_offset, chunk), - PopupBody::Keybinds { - description, - formatted_keybinds, - } => paint_help( - f, - &scene.title, - description.as_deref(), - formatted_keybinds, - scene.scroll_offset, - chunk, - ), - PopupBody::ReviewTrailers { - reviewed_by, - tested_by, - acked_by, - } => paint_review_trailers( - f, - reviewed_by, - tested_by, - acked_by, - scene.scroll_offset, - chunk, - ), - PopupBody::Confirm { - body, - options, - selected, - } => paint_confirm(f, &scene.title, body, options, *selected, chunk), + pub fn paint(f: &mut Frame, scene: &PopupScene, chunk: Rect) { + match &scene.body { + PopupBody::Text(body) => { + Self::paint_info(f, &scene.title, body, scene.scroll_offset, chunk) + } + PopupBody::Keybinds { + description, + formatted_keybinds, + } => Self::paint_help( + f, + &scene.title, + description.as_deref(), + formatted_keybinds, + scene.scroll_offset, + chunk, + ), + PopupBody::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + } => Self::paint_review_trailers( + f, + reviewed_by, + tested_by, + acked_by, + scene.scroll_offset, + chunk, + ), + PopupBody::Confirm { + body, + options, + selected, + } => Self::paint_confirm(f, &scene.title, body, options, *selected, chunk), + } } -} -fn paint_info(f: &mut Frame, title: &str, body: &str, scroll: (u16, u16), chunk: Rect) { - let bold_blue = Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::Blue); - let block = Block::default() - .title(title.to_string()) - .title_alignment(Alignment::Center) - .title_style(bold_blue) - .title_bottom(Line::styled("(ESC / q) Close", bold_blue)) - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); - - let paragraph = Paragraph::new(body.to_string()) - .block(block) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) - .scroll(scroll); - - f.render_widget(Clear, chunk); - f.render_widget(paragraph, chunk); -} - -fn paint_help( - f: &mut Frame, - title: &str, - description: Option<&str>, - formatted_keybinds: &str, - scroll: (u16, u16), - chunk: Rect, -) { - let block = Block::default() - .title(title.to_string()) - .title_alignment(Alignment::Center) - .title_style(Style::default().bold().blue()) - .title_bottom(Line::styled( - "(ESC / q) Close", - Style::default().bold().blue(), - )) - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); - - let mut text = description.map_or_else(String::new, |d| format!("{d}\n\n")); - if !formatted_keybinds.is_empty() { - text.push_str(" \u{1F836} Keybinds\n"); - text.push_str(formatted_keybinds); + fn paint_info(f: &mut Frame, title: &str, body: &str, scroll: (u16, u16), chunk: Rect) { + let bold_blue = Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Blue); + let block = Block::default() + .title(title.to_string()) + .title_alignment(Alignment::Center) + .title_style(bold_blue) + .title_bottom(Line::styled("(ESC / q) Close", bold_blue)) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); + + let paragraph = Paragraph::new(body.to_string()) + .block(block) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .scroll(scroll); + + f.render_widget(Clear, chunk); + f.render_widget(paragraph, chunk); } - let paragraph = Paragraph::new(text) - .style(Style::default()) - .block(block) - .alignment(Alignment::Left) - .scroll(scroll); + fn paint_help( + f: &mut Frame, + title: &str, + description: Option<&str>, + formatted_keybinds: &str, + scroll: (u16, u16), + chunk: Rect, + ) { + let block = Block::default() + .title(title.to_string()) + .title_alignment(Alignment::Center) + .title_style(Style::default().bold().blue()) + .title_bottom(Line::styled( + "(ESC / q) Close", + Style::default().bold().blue(), + )) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); + + let mut text = description.map_or_else(String::new, |d| format!("{d}\n\n")); + if !formatted_keybinds.is_empty() { + text.push_str(" \u{1F836} Keybinds\n"); + text.push_str(formatted_keybinds); + } - f.render_widget(Clear, chunk); - f.render_widget(paragraph, chunk); -} + let paragraph = Paragraph::new(text) + .style(Style::default()) + .block(block) + .alignment(Alignment::Left) + .scroll(scroll); -fn paint_review_trailers( - f: &mut Frame, - reviewed_by: &str, - tested_by: &str, - acked_by: &str, - scroll: (u16, u16), - chunk: Rect, -) { - let header_style = Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::UNDERLINED); - - let mut contents: Vec> = vec![]; - - let mut push_section = |name: &str, text: &str| { - contents.push(Line::styled(name.to_string(), header_style)); - for line in text.lines() { - contents.push(Line::styled( - line.to_string(), - Style::default().fg(Color::White), - )); - } - contents.push(Line::from("")); - }; - - push_section("Reviewed-by", reviewed_by); - push_section("Tested-by", tested_by); - push_section("Acked-by", acked_by); - - let block = Block::default() - .title("Code-Review Trailers") - .title_alignment(Alignment::Center) - .title_style(Style::default().bold().blue()) - .title_bottom(Line::styled( - "(ESC / q) Close", - Style::default().bold().blue(), - )) - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); - - let paragraph = Paragraph::new(contents) - .style(Style::default()) - .block(block) - .alignment(Alignment::Left) - .scroll(scroll); - - f.render_widget(Clear, chunk); - f.render_widget(paragraph, chunk); -} + f.render_widget(Clear, chunk); + f.render_widget(paragraph, chunk); + } -fn paint_confirm( - f: &mut Frame, - title: &str, - body: &str, - options: &[String], - selected: usize, - chunk: Rect, -) { - let bold_blue = Style::default() - .add_modifier(Modifier::BOLD) - .fg(Color::Blue); - let selected_style = Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - .add_modifier(Modifier::REVERSED); - let block = Block::default() - .title(title.to_string()) - .title_alignment(Alignment::Center) - .title_style(bold_blue) - .title_bottom(Line::styled("(ENTER) confirm | (ESC / q) wait", bold_blue)) - .borders(Borders::ALL) - .border_type(BorderType::Double) - .style(Style::default()); - - let mut lines = vec![Line::from(body.to_string()), Line::from("")]; - let mut option_spans = Vec::new(); - for (index, label) in options.iter().enumerate() { - if index > 0 { - option_spans.push(Span::raw(" ")); - } - if index == selected { - option_spans.push(Span::styled(format!("[{label}]"), selected_style)); - } else { - option_spans.push(Span::raw(format!(" {label} "))); - } + fn paint_review_trailers( + f: &mut Frame, + reviewed_by: &str, + tested_by: &str, + acked_by: &str, + scroll: (u16, u16), + chunk: Rect, + ) { + let header_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::UNDERLINED); + + let mut contents: Vec> = vec![]; + + let mut push_section = |name: &str, text: &str| { + contents.push(Line::styled(name.to_string(), header_style)); + for line in text.lines() { + contents.push(Line::styled( + line.to_string(), + Style::default().fg(Color::White), + )); + } + contents.push(Line::from("")); + }; + + push_section("Reviewed-by", reviewed_by); + push_section("Tested-by", tested_by); + push_section("Acked-by", acked_by); + + let block = Block::default() + .title("Code-Review Trailers") + .title_alignment(Alignment::Center) + .title_style(Style::default().bold().blue()) + .title_bottom(Line::styled( + "(ESC / q) Close", + Style::default().bold().blue(), + )) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); + + let paragraph = Paragraph::new(contents) + .style(Style::default()) + .block(block) + .alignment(Alignment::Left) + .scroll(scroll); + + f.render_widget(Clear, chunk); + f.render_widget(paragraph, chunk); } - lines.push(Line::from(option_spans)); - let paragraph = Paragraph::new(lines) - .block(block) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }); + fn paint_confirm( + f: &mut Frame, + title: &str, + body: &str, + options: &[String], + selected: usize, + chunk: Rect, + ) { + let bold_blue = Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Blue); + let selected_style = Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED); + let block = Block::default() + .title(title.to_string()) + .title_alignment(Alignment::Center) + .title_style(bold_blue) + .title_bottom(Line::styled("(ENTER) confirm | (ESC / q) wait", bold_blue)) + .borders(Borders::ALL) + .border_type(BorderType::Double) + .style(Style::default()); + + let mut lines = vec![Line::from(body.to_string()), Line::from("")]; + let mut option_spans = Vec::new(); + for (index, label) in options.iter().enumerate() { + if index > 0 { + option_spans.push(Span::raw(" ")); + } + if index == selected { + option_spans.push(Span::styled(format!("[{label}]"), selected_style)); + } else { + option_spans.push(Span::raw(format!(" {label} "))); + } + } + lines.push(Line::from(option_spans)); + + let paragraph = Paragraph::new(lines) + .block(block) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }); - f.render_widget(Clear, chunk); - f.render_widget(paragraph, chunk); + f.render_widget(Clear, chunk); + f.render_widget(paragraph, chunk); + } } From 7a5f952911f71a944653bc045dda06dfe387977e Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:24:07 -0300 Subject: [PATCH 27/61] refactor: share one actor reply helper across actors ActorReplyService::send_actor_reply replaces the result-reply copies. They shared the same control flow and differed only in the static failure and dropped-receiver log lines, which each call site still passes through. Value replies that never log a failure use deliver_value. The reply log events' tracing target, file and line now point at `infrastructure::actor_reply` instead of each actor's module, while message text, level and fields are unchanged (the `patch_hub` prefix filter still matches). No tests removed. Signed-off-by: lorenzoberts --- src/config/actor.rs | 55 +++++----------- src/infrastructure/actor_reply.rs | 39 ++++++++++++ src/infrastructure/mod.rs | 1 + src/kw/actor/mod.rs | 102 ++++++++++++------------------ src/lore/application/actor.rs | 92 +++++++++++++++++---------- src/render/actor.rs | 36 +++-------- src/terminal/actor.rs | 84 ++++++++++++++---------- src/ui/actor.rs | 41 +++--------- 8 files changed, 225 insertions(+), 225 deletions(-) create mode 100644 src/infrastructure/actor_reply.rs diff --git a/src/config/actor.rs b/src/config/actor.rs index 55822ec..5a9afcc 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -5,11 +5,9 @@ //! and persists successful updates through [`JsonConfigRepository`](crate::config::JsonConfigRepository). use std::ops::ControlFlow; -use tokio::{ - spawn, - sync::{mpsc, oneshot}, -}; +use tokio::{spawn, sync::mpsc}; +use crate::infrastructure::actor_reply::ActorReplyService; use crate::{ config::{ handle::ConfigHandle, @@ -66,12 +64,23 @@ where match message { ConfigMessage::GetSnapshot { reply } => { - send_config_snapshot_reply(message_name, reply, self.state.to_snapshot()); + ActorReplyService::deliver_value( + message_name, + "config reply receiver dropped before response", + reply, + self.state.to_snapshot(), + ); ControlFlow::Continue(()) } ConfigMessage::ValidateAndApply { draft, reply } => { let result = self.apply(*draft); - send_config_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "config request failed", + "config reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } ConfigMessage::Shutdown => { @@ -91,40 +100,6 @@ where } } -fn send_config_snapshot_reply( - message_name: &'static str, - reply: oneshot::Sender, - snapshot: ConfigSnapshot, -) { - if reply.send(snapshot).is_err() { - tracing::warn!( - message = message_name, - "config reply receiver dropped before response" - ); - } -} - -fn send_config_reply( - message_name: &'static str, - reply: oneshot::Sender>, - result: ConfigResult, -) { - if let Err(error) = &result { - tracing::warn!( - message = message_name, - error = %error, - "config request failed" - ); - } - - if reply.send(result).is_err() { - tracing::warn!( - message = message_name, - "config reply receiver dropped before response" - ); - } -} - #[cfg(test)] mod tests { use std::{ diff --git a/src/infrastructure/actor_reply.rs b/src/infrastructure/actor_reply.rs new file mode 100644 index 0000000..051c757 --- /dev/null +++ b/src/infrastructure/actor_reply.rs @@ -0,0 +1,39 @@ +use std::fmt::Display; + +use tokio::sync::oneshot; + +/// Sends a oneshot actor reply and records when that reply is lost. +pub struct ActorReplyService; + +impl ActorReplyService { + pub fn send_actor_reply( + message_name: &'static str, + failure_log: &'static str, + dropped_log: &'static str, + reply: oneshot::Sender>, + result: Result, + ) { + if let Err(error) = &result { + tracing::warn!( + message = message_name, + error = %error, + "{failure_log}" + ); + } + + if reply.send(result).is_err() { + tracing::warn!(message = message_name, "{dropped_log}"); + } + } + + pub fn deliver_value( + message_name: &'static str, + dropped_log: &'static str, + reply: oneshot::Sender, + value: T, + ) { + if reply.send(value).is_err() { + tracing::warn!(message = message_name, "{dropped_log}"); + } + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 48908ad..d9bc4d0 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,3 +1,4 @@ +pub mod actor_reply; pub mod env; pub mod errors; pub mod file_system; diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index d48405b..c56f2b6 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -25,6 +25,7 @@ use tokio::{ sync::{mpsc, oneshot, watch}, }; +use crate::infrastructure::actor_reply::ActorReplyService; use crate::{ config::KernelTree, infrastructure::{ @@ -146,47 +147,71 @@ impl KwActor { match message { KwMessage::RecordApply { record, reply } => { - send_kw_reply( + ActorReplyService::send_actor_reply( message_name, + "kw request failed", + "kw reply receiver dropped before response", reply, self.history.record_apply(record).map_err(KwError::from), ); ControlFlow::Continue(()) } KwMessage::StartBuild { request, reply } => { - send_start_reply( + ActorReplyService::send_actor_reply( message_name, + "kw start request refused", + "kw reply receiver dropped before response", reply, self.start_job(KwJobKind::Build, request).await, ); ControlFlow::Continue(()) } KwMessage::StartDeploy { request, reply } => { - send_start_reply( + ActorReplyService::send_actor_reply( message_name, + "kw start request refused", + "kw reply receiver dropped before response", reply, self.start_job(KwJobKind::Deploy, request).await, ); ControlFlow::Continue(()) } KwMessage::StartBuildThenDeploy { request, reply } => { - send_start_reply( + ActorReplyService::send_actor_reply( message_name, + "kw start request refused", + "kw reply receiver dropped before response", reply, self.start_job(KwJobKind::BuildThenDeploy, request).await, ); ControlFlow::Continue(()) } KwMessage::Cancel { reply } => { - send_kw_reply(message_name, reply, self.request_cancel()); + ActorReplyService::send_actor_reply( + message_name, + "kw request failed", + "kw reply receiver dropped before response", + reply, + self.request_cancel(), + ); ControlFlow::Continue(()) } KwMessage::GetStatus { reply } => { - send_value_reply(message_name, reply, self.status_tx.borrow().clone()); + ActorReplyService::deliver_value( + message_name, + "kw reply receiver dropped before response", + reply, + self.status_tx.borrow().clone(), + ); ControlFlow::Continue(()) } KwMessage::WatchStatus { reply } => { - send_value_reply(message_name, reply, self.status_tx.subscribe()); + ActorReplyService::deliver_value( + message_name, + "kw reply receiver dropped before response", + reply, + self.status_tx.subscribe(), + ); ControlFlow::Continue(()) } KwMessage::GetReadiness { @@ -195,8 +220,10 @@ impl KwActor { for_branch, reply, } => { - send_kw_reply( + ActorReplyService::send_actor_reply( message_name, + "kw request failed", + "kw reply receiver dropped before response", reply, self.evaluate_readiness(&kernel_tree_id, &tree, for_branch) .await, @@ -204,7 +231,13 @@ impl KwActor { ControlFlow::Continue(()) } KwMessage::RestorePreviousBranch { reply } => { - send_kw_reply(message_name, reply, self.restore_previous_branch().await); + ActorReplyService::send_actor_reply( + message_name, + "kw request failed", + "kw reply receiver dropped before response", + reply, + self.restore_previous_branch().await, + ); ControlFlow::Continue(()) } KwMessage::Shutdown { reply } => { @@ -1139,57 +1172,6 @@ impl KwGitService { } } -fn send_kw_reply( - message_name: &'static str, - reply: oneshot::Sender>, - result: Result, -) { - if let Err(error) = &result { - tracing::warn!( - message = message_name, - error = %error, - "kw request failed" - ); - } - - if reply.send(result).is_err() { - tracing::warn!( - message = message_name, - "kw reply receiver dropped before response" - ); - } -} - -fn send_start_reply( - message_name: &'static str, - reply: oneshot::Sender>, - result: Result<(), KwStartError>, -) { - if let Err(error) = &result { - tracing::warn!( - message = message_name, - error = %error, - "kw start request refused" - ); - } - - if reply.send(result).is_err() { - tracing::warn!( - message = message_name, - "kw reply receiver dropped before response" - ); - } -} - -fn send_value_reply(message_name: &'static str, reply: oneshot::Sender, value: T) { - if reply.send(value).is_err() { - tracing::warn!( - message = message_name, - "kw reply receiver dropped before response" - ); - } -} - #[cfg(test)] mod actor_test; #[cfg(test)] diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index c110171..ed17c10 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -7,12 +7,9 @@ //! [`LoreApiActor::with_core`]; callers never touch [`LoreService`] directly. use std::ops::ControlFlow; -use tokio::{ - spawn, - sync::{mpsc, oneshot}, - task, -}; +use tokio::{spawn, sync::mpsc, task}; +use crate::infrastructure::actor_reply::ActorReplyService; use crate::lore::application::{ errors::LoreError, handle::LoreApiHandle, messages::LoreApiMessage, service::LoreService, }; @@ -63,7 +60,13 @@ impl LoreApiActor { .with_core(|core| core.warm_bootstrap_cache()) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::FetchAvailableLists { cache_mode, reply } => { @@ -72,7 +75,13 @@ impl LoreApiActor { .with_core(move |core| core.fetch_available_lists(cache_mode)) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::FetchFeedPage { @@ -95,7 +104,13 @@ impl LoreApiActor { }) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::FetchPatchsetDetails { @@ -114,7 +129,13 @@ impl LoreApiActor { }) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::SaveBookmarks { bookmarks, reply } => { @@ -123,7 +144,13 @@ impl LoreApiActor { .with_core(move |core| core.save_bookmarked_patchsets(&bookmarks)) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::SaveReviewed { reviewed, reply } => { @@ -132,7 +159,13 @@ impl LoreApiActor { .with_core(move |core| core.save_reviewed_patchsets(&reviewed)) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::GetGitSignature { @@ -143,7 +176,13 @@ impl LoreApiActor { let result = self .with_core(move |core| core.get_git_signature(&git_repo_path)) .await; - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::PrepareReplyCommands { @@ -174,7 +213,13 @@ impl LoreApiActor { }) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "lore api request failed", + "lore api reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::Shutdown => { @@ -204,27 +249,6 @@ impl LoreApiActor { } } -fn send_lore_reply( - message_name: &'static str, - reply: oneshot::Sender>, - result: Result, -) { - if let Err(error) = &result { - tracing::warn!( - message = message_name, - error = %error, - "lore api request failed" - ); - } - - if reply.send(result).is_err() { - tracing::warn!( - message = message_name, - "lore api reply receiver dropped before response" - ); - } -} - #[cfg(test)] mod tests { use std::{collections::HashMap, sync::Arc}; diff --git a/src/render/actor.rs b/src/render/actor.rs index fa1b13b..e41a7e1 100644 --- a/src/render/actor.rs +++ b/src/render/actor.rs @@ -8,12 +8,9 @@ //! and the UI thread. use std::ops::ControlFlow; -use tokio::{ - spawn, - sync::{mpsc, oneshot}, - task, -}; +use tokio::{spawn, sync::mpsc, task}; +use crate::infrastructure::actor_reply::ActorReplyService; use crate::render::{ handle::RenderHandle, messages::{RenderMessage, RenderResult}, @@ -71,7 +68,13 @@ impl RenderActor { .with_core(move |core| core.render_patchset_preview(request)) .await .and_then(|result| result); - send_render_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "render request failed", + "render reply receiver dropped before response", + reply, + result, + ); ControlFlow::Continue(()) } RenderMessage::Shutdown => { @@ -101,27 +104,6 @@ impl RenderActor { } } -fn send_render_reply( - message_name: &'static str, - reply: oneshot::Sender>, - result: RenderResult, -) { - if let Err(error) = &result { - tracing::warn!( - message = message_name, - error = %error, - "render request failed" - ); - } - - if reply.send(result).is_err() { - tracing::warn!( - message = message_name, - "render reply receiver dropped before response" - ); - } -} - #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index 4ea0bd2..b524088 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -5,12 +5,9 @@ //! implementation ([`TerminalSessionApi`](crate::terminal::session::TerminalSessionApi), //! e.g. crossterm) is moved into the actor at spawn time so no other component //! holds the terminal directly. -use tokio::{ - spawn, - sync::{mpsc, oneshot}, - task, -}; +use tokio::{spawn, sync::mpsc, task}; +use crate::infrastructure::actor_reply::ActorReplyService; use crate::terminal::{ handle::TerminalHandle, messages::{TerminalMessage, TerminalResult}, @@ -61,28 +58,52 @@ impl TerminalActor { .with_session(move |session| session.draw(frame)) .await .and_then(|result| result); - send_terminal_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "terminal request failed", + "terminal reply receiver dropped before response", + reply, + result, + ); } TerminalMessage::PollEvent { timeout, reply } => { let result = self .with_session(move |session| session.poll_event(timeout)) .await .and_then(|result| result); - send_terminal_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "terminal request failed", + "terminal reply receiver dropped before response", + reply, + result, + ); } TerminalMessage::SetupUserIo { reply } => { let result = self .with_session(|session| session.setup_user_io()) .await .and_then(|result| result); - send_terminal_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "terminal request failed", + "terminal reply receiver dropped before response", + reply, + result, + ); } TerminalMessage::TeardownUserIo { reply } => { let result = self .with_session(|session| session.teardown_user_io()) .await .and_then(|result| result); - send_terminal_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "terminal request failed", + "terminal reply receiver dropped before response", + reply, + result, + ); } TerminalMessage::WaitForKeyPress { key, @@ -93,21 +114,39 @@ impl TerminalActor { .with_session(move |session| session.wait_for_key_press(key, timeout)) .await .and_then(|result| result); - send_terminal_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "terminal request failed", + "terminal reply receiver dropped before response", + reply, + result, + ); } TerminalMessage::GetSize { reply } => { let result = self .with_session(|session| session.size()) .await .and_then(|result| result); - send_terminal_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "terminal request failed", + "terminal reply receiver dropped before response", + reply, + result, + ); } TerminalMessage::Shutdown { reply } => { let result = self .with_session(|session| session.shutdown()) .await .and_then(|result| result); - send_terminal_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + "terminal request failed", + "terminal reply receiver dropped before response", + reply, + result, + ); } } } @@ -132,27 +171,6 @@ impl TerminalActor { } } -fn send_terminal_reply( - message_name: &'static str, - reply: oneshot::Sender>, - result: TerminalResult, -) { - if let Err(error) = &result { - tracing::warn!( - message = message_name, - error = %error, - "terminal request failed" - ); - } - - if reply.send(result).is_err() { - tracing::warn!( - message = message_name, - "terminal reply receiver dropped before response" - ); - } -} - #[cfg(test)] mod tests { use std::time::Duration; diff --git a/src/ui/actor.rs b/src/ui/actor.rs index c6aab85..fae7220 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -10,16 +10,10 @@ //! crossing this boundary. use std::{mem, ops::ControlFlow}; -use tokio::{ - spawn, - sync::{mpsc, oneshot}, -}; +use tokio::{spawn, sync::mpsc}; -use crate::ui::{ - core::UiCore, - handle::UiHandle, - messages::{UiMessage, UiResult}, -}; +use crate::infrastructure::actor_reply::ActorReplyService; +use crate::ui::{core::UiCore, handle::UiHandle, messages::UiMessage}; pub const DEFAULT_UI_CHANNEL_SIZE: usize = 32; @@ -66,7 +60,13 @@ impl UiActor { ); let result = self.core.build_scene(&app_view); tracing::debug!(ok = result.is_ok(), "ui scene built"); - send_ui_reply(message_name, reply_to, result); + ActorReplyService::send_actor_reply( + message_name, + "ui request failed", + "ui reply receiver dropped before response", + reply_to, + result, + ); ControlFlow::Continue(()) } UiMessage::Shutdown => { @@ -77,27 +77,6 @@ impl UiActor { } } -fn send_ui_reply( - message_name: &'static str, - reply: oneshot::Sender>, - result: UiResult, -) { - if let Err(error) = &result { - tracing::warn!( - message = message_name, - error = %error, - "ui request failed" - ); - } - - if reply.send(result).is_err() { - tracing::warn!( - message = message_name, - "ui reply receiver dropped before response" - ); - } -} - #[cfg(test)] mod tests { use tokio::sync::mpsc; From e32bb9142a26a192aa1eed59af043ffb291240f2 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:46:43 -0300 Subject: [PATCH 28/61] refactor(lore): group parsing and reply helpers into services Signed-off-by: lorenzoberts --- src/lore/application/service.rs | 79 +++--- src/lore/domain/patch.rs | 26 +- src/lore/domain/patchset.rs | 4 +- src/lore/infrastructure/parsers.rs | 100 ++++---- src/lore/infrastructure/patchset_fetcher.rs | 30 +-- src/lore/infrastructure/patchset_parser.rs | 264 ++++++++++---------- src/render/mod.rs | 4 +- 7 files changed, 263 insertions(+), 244 deletions(-) diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index d916100..9d2916f 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -27,9 +27,9 @@ use crate::{ }, infrastructure::{ http_lore_client::{FeedGateway, ListsGateway, LoreHttpError, PatchHtmlGateway}, - parsers, + parsers::LoreParserService, patchset_fetcher::PatchsetFetcher, - patchset_parser::{self, PatchsetParser}, + patchset_parser::{PatchsetParser, PatchsetParserService}, persistence::{MailingListsCacheStore, UserLoreStateStore}, }, }, @@ -128,7 +128,7 @@ impl LoreService { let body = gateway .fetch_available_lists_page(offset) .map_err(LoreError::Http)?; - let page = parsers::parse_available_lists(&body); + let page = LoreParserService::parse_available_lists(&body); if page.is_empty() { break; } @@ -225,7 +225,8 @@ impl LoreService { let offset = self.cache.feeds[target_list].index.next_offset(); match gateway.fetch_patch_feed_page(target_list, offset) { Ok(body) => { - let feed = parsers::parse_patch_feed(&body).map_err(LoreError::Parse)?; + let feed = + LoreParserService::parse_patch_feed(&body).map_err(LoreError::Parse)?; let Some(entry) = self.cache.feeds.get_mut(target_list) else { return Err(LoreError::Parse(format!( "feed cache entry missing for {target_list}" @@ -290,8 +291,10 @@ impl LoreService { .split_patchset(&patchset_path) .map_err(LoreError::Parse)?; - let tag_summary: Vec = - raw_patches.iter().map(|p| extract_tag_summary(p)).collect(); + let tag_summary: Vec = raw_patches + .iter() + .map(|p| Self::extract_tag_summary(p)) + .collect(); match mode { CacheMode::Bypass => {} @@ -342,7 +345,7 @@ impl LoreService { .ok_or_else(|| LoreError::Parse("Message-Id header not found".to_string()))?; let reply_path = tmp_dir.join(format!("{message_id}-reply.mbx")); - let mut reply = patchset_parser::generate_reply_template(patch); + let mut reply = PatchsetParserService::generate_reply_template(patch); reply.push_str(&format!("\nReviewed-by: {git_signature}\n")); self.fs .write(&reply_path, reply.as_bytes()) @@ -352,7 +355,7 @@ impl LoreService { .fetch_patch_html(target_list, message_id) .map_err(LoreError::Http)?; - let command = patchset_parser::extract_git_reply_command( + let command = PatchsetParserService::extract_git_reply_command( &patch_html, git_send_email_options, &format!("{}", reply_path.display()), @@ -419,38 +422,38 @@ impl LoreService { (name, email) } -} -fn extract_tag_summary(raw_patch: &str) -> PatchTagSummary { - let (cover, _) = patchset_parser::split_cover(raw_patch); - - let mut reviewed_by = HashSet::new(); - let mut tested_by = HashSet::new(); - let mut acked_by = HashSet::new(); - - for line in cover.lines() { - let line = line.trim_start(); - for (prefix, set) in [ - ("Reviewed-by:", &mut reviewed_by), - ("Tested-by:", &mut tested_by), - ("Acked-by:", &mut acked_by), - ] { - if let Some(rest) = line.strip_prefix(prefix) { - let parts: Vec<&str> = rest.trim().split('<').collect(); - if parts.len() == 2 { - let name = parts[0].trim().to_string(); - let email = parts[1].trim_end_matches('>').trim().to_string(); - set.insert(Author { name, email }); + fn extract_tag_summary(raw_patch: &str) -> PatchTagSummary { + let (cover, _) = PatchsetParserService::split_cover(raw_patch); + + let mut reviewed_by = HashSet::new(); + let mut tested_by = HashSet::new(); + let mut acked_by = HashSet::new(); + + for line in cover.lines() { + let line = line.trim_start(); + for (prefix, set) in [ + ("Reviewed-by:", &mut reviewed_by), + ("Tested-by:", &mut tested_by), + ("Acked-by:", &mut acked_by), + ] { + if let Some(rest) = line.strip_prefix(prefix) { + let parts: Vec<&str> = rest.trim().split('<').collect(); + if parts.len() == 2 { + let name = parts[0].trim().to_string(); + let email = parts[1].trim_end_matches('>').trim().to_string(); + set.insert(Author { name, email }); + } + break; } - break; } } - } - PatchTagSummary { - reviewed_by, - tested_by, - acked_by, + PatchTagSummary { + reviewed_by, + tested_by, + acked_by, + } } } @@ -755,9 +758,9 @@ mod tests { ); let feed = { - use crate::lore::infrastructure::parsers::parse_patch_feed; + use crate::lore::infrastructure::parsers::LoreParserService; let xml = fs::read_to_string(src).unwrap(); - parse_patch_feed(&xml).unwrap() + LoreParserService::parse_patch_feed(&xml).unwrap() }; let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); @@ -796,7 +799,7 @@ mod tests { // Pre-populate the cache. let feed = { let xml = fs::read_to_string(src).unwrap(); - crate::lore::infrastructure::parsers::parse_patch_feed(&xml).unwrap() + crate::lore::infrastructure::parsers::LoreParserService::parse_patch_feed(&xml).unwrap() }; let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); diff --git a/src/lore/domain/patch.rs b/src/lore/domain/patch.rs index dd6bc4c..6bfdc7a 100644 --- a/src/lore/domain/patch.rs +++ b/src/lore/domain/patch.rs @@ -15,13 +15,13 @@ pub struct PatchFeed { #[derive(Getters, Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Patch { r#title: String, - #[serde(default = "default_version")] + #[serde(default = "Patch::default_version")] #[getter(skip)] version: usize, - #[serde(default = "default_number_in_series")] + #[serde(default = "Patch::default_number_in_series")] #[getter(skip)] number_in_series: usize, - #[serde(default = "default_total_in_series")] + #[serde(default = "Patch::default_total_in_series")] #[getter(skip)] total_in_series: usize, author: Author, @@ -45,17 +45,17 @@ impl Display for Author { } } -fn default_version() -> usize { - 1 -} -fn default_number_in_series() -> usize { - 1 -} -fn default_total_in_series() -> usize { - 1 -} - impl Patch { + fn default_version() -> usize { + 1 + } + fn default_number_in_series() -> usize { + 1 + } + fn default_total_in_series() -> usize { + 1 + } + pub fn version(&self) -> usize { self.version } diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index 112b215..880af06 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -117,11 +117,11 @@ mod tests { use std::fs; use super::*; - use crate::lore::infrastructure::parsers::parse_patch_feed; + use crate::lore::infrastructure::parsers::LoreParserService; fn feed_from_file(path: &str) -> PatchFeed { let xml = fs::read_to_string(path).unwrap(); - parse_patch_feed(&xml).unwrap() + LoreParserService::parse_patch_feed(&xml).unwrap() } #[test] diff --git a/src/lore/infrastructure/parsers.rs b/src/lore/infrastructure/parsers.rs index cb5baa7..95df372 100644 --- a/src/lore/infrastructure/parsers.rs +++ b/src/lore/infrastructure/parsers.rs @@ -3,56 +3,60 @@ use std::sync::LazyLock; use crate::lore::domain::{mailing_list::MailingList, patch::PatchFeed}; -/// Parses an Atom feed XML body into a [`PatchFeed`]. -pub fn parse_patch_feed(xml: &str) -> Result { - serde_xml_rs::from_str(xml).map_err(|e| e.to_string()) -} +pub struct LoreParserService; -/// Parses the HTML body returned by the Lore available-lists endpoint into a -/// sorted [`Vec`]. -pub fn parse_available_lists(html: &str) -> Vec { - static RE_PRE_BLOCK: LazyLock = - LazyLock::new(|| Regex::new(r#"(?s)
(.*?)
"#).expect("valid pre block regex")); - static RE_LIST_NAME: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?s)(.*?)"#).expect("valid list name regex") - }); - static RE_LIST_DESCRIPTION: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?s)\s*(.*?)\s*\*"#).expect("valid list description regex") - }); - - let mut list_names: Vec<&str> = Vec::new(); - let mut list_descriptions: Vec<&str> = Vec::new(); - let mut available_lists: Vec = Vec::new(); - - let pre_blocks: Vec<&str> = RE_PRE_BLOCK - .captures_iter(html) - .filter_map(|cap| cap.get(1).map(|m| m.as_str())) - .collect(); - - if pre_blocks.len() < 3 { - return available_lists; +impl LoreParserService { + /// Parses an Atom feed XML body into a [`PatchFeed`]. + pub fn parse_patch_feed(xml: &str) -> Result { + serde_xml_rs::from_str(xml).map_err(|e| e.to_string()) } - list_names.extend( - RE_LIST_NAME - .captures_iter(pre_blocks[2]) - .filter_map(|capture| capture.get(1).map(|m| m.as_str().trim())), - ); - - list_descriptions.extend( - RE_LIST_DESCRIPTION - .captures_iter(pre_blocks[2]) - .filter_map(|capture| capture.get(1).map(|m| m.as_str().trim())), - ); - - for (name, description) in list_names.into_iter().zip(list_descriptions) { - if name == "all" { - continue; + /// Parses the HTML body returned by the Lore available-lists endpoint into a + /// sorted [`Vec`]. + pub fn parse_available_lists(html: &str) -> Vec { + static RE_PRE_BLOCK: LazyLock = + LazyLock::new(|| Regex::new(r#"(?s)
(.*?)
"#).expect("valid pre block regex")); + static RE_LIST_NAME: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)(.*?)"#).expect("valid list name regex") + }); + static RE_LIST_DESCRIPTION: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)\s*(.*?)\s*\*"#).expect("valid list description regex") + }); + + let mut list_names: Vec<&str> = Vec::new(); + let mut list_descriptions: Vec<&str> = Vec::new(); + let mut available_lists: Vec = Vec::new(); + + let pre_blocks: Vec<&str> = RE_PRE_BLOCK + .captures_iter(html) + .filter_map(|cap| cap.get(1).map(|m| m.as_str())) + .collect(); + + if pre_blocks.len() < 3 { + return available_lists; } - available_lists.push(MailingList::new(name, description)); - } - available_lists + list_names.extend( + RE_LIST_NAME + .captures_iter(pre_blocks[2]) + .filter_map(|capture| capture.get(1).map(|m| m.as_str().trim())), + ); + + list_descriptions.extend( + RE_LIST_DESCRIPTION + .captures_iter(pre_blocks[2]) + .filter_map(|capture| capture.get(1).map(|m| m.as_str().trim())), + ); + + for (name, description) in list_names.into_iter().zip(list_descriptions) { + if name == "all" { + continue; + } + available_lists.push(MailingList::new(name, description)); + } + + available_lists + } } #[cfg(test)] @@ -68,7 +72,7 @@ mod tests { ) .unwrap(); - let lists = parse_available_lists(&html); + let lists = LoreParserService::parse_available_lists(&html); assert_eq!(199, lists.len(), "Should've processed 199 lists"); assert_eq!("linux-mm", lists[0].name()); @@ -87,14 +91,14 @@ mod tests { ) .unwrap(); - let feed = parse_patch_feed(&xml); + let feed = LoreParserService::parse_patch_feed(&xml); assert!(feed.is_ok(), "Should parse a valid feed XML"); assert!(!feed.unwrap().patches().is_empty()); } #[test] fn parse_patch_feed_returns_error_for_invalid_xml() { - let result = parse_patch_feed("this is not xml"); + let result = LoreParserService::parse_patch_feed("this is not xml"); assert!(result.is_err()); } } diff --git a/src/lore/infrastructure/patchset_fetcher.rs b/src/lore/infrastructure/patchset_fetcher.rs index 380c4c7..3fae1b9 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -40,12 +40,25 @@ impl B4PatchsetFetcher { cache_dir, } } + + fn extract_mbox_name_from_message_id(message_id: &str) -> String { + let mut mbox_name = message_id + .replace("http://lore.kernel.org/", "") + .replace("https://lore.kernel.org/", "") + .replace('/', "."); + + if !mbox_name.ends_with('.') { + mbox_name.push('.'); + } + mbox_name.push_str("mbx"); + mbox_name + } } impl PatchsetFetcher for B4PatchsetFetcher { fn download(&self, patch: &Patch) -> Result { let message_id: &str = &patch.message_id().href; - let mbox_name = extract_mbox_name_from_message_id(message_id); + let mbox_name = Self::extract_mbox_name_from_message_id(message_id); let output_dir = &self.cache_dir; if !self.fs.exists(Path::new(output_dir)) @@ -87,19 +100,6 @@ impl PatchsetFetcher for B4PatchsetFetcher { } } -fn extract_mbox_name_from_message_id(message_id: &str) -> String { - let mut mbox_name = message_id - .replace("http://lore.kernel.org/", "") - .replace("https://lore.kernel.org/", "") - .replace('/', "."); - - if !mbox_name.ends_with('.') { - mbox_name.push('.'); - } - mbox_name.push_str("mbx"); - mbox_name -} - #[cfg(test)] mod tests { use super::*; @@ -127,7 +127,7 @@ mod tests { "#, ); - crate::lore::infrastructure::parsers::parse_patch_feed(&xml) + crate::lore::infrastructure::parsers::LoreParserService::parse_patch_feed(&xml) .unwrap() .patches()[0] .clone() diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index d012618..6a656a9 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -24,6 +24,58 @@ impl MboxPatchsetParser { pub fn new(fs: Arc) -> Self { MboxPatchsetParser { fs } } + + fn extract_patches( + fs: &dyn FileSystemTrait, + mbox_path: &Path, + patches: &mut Vec, + ) -> Result<(), String> { + let mut current_patch = String::new(); + let mut is_reading_patch = false; + let mut is_last_line = false; + + let mbox_reader = fs + .open_bufreader(mbox_path) + .map_err(|err| format!("{}: {err}", mbox_path.display()))?; + + for line in mbox_reader.lines() { + let line = line.map_err(|err| format!("{}: {err}", mbox_path.display()))?; + + if line.starts_with("Subject: ") { + is_reading_patch = true; + } else if is_reading_patch && line.trim_end().eq("--") { + is_last_line = true; + } else if is_last_line { + current_patch.push_str(&line); + current_patch.push('\n'); + + let mut patch_to_add = String::new(); + swap(&mut patch_to_add, &mut current_patch); + patches.push(patch_to_add); + + is_reading_patch = false; + is_last_line = false; + } else if is_reading_patch && line.trim_end().eq("From git@z Thu Jan 1 00:00:00 1970") + { + let mut patch_to_add = String::new(); + swap(&mut patch_to_add, &mut current_patch); + patches.push(patch_to_add); + + is_reading_patch = false; + } + + if is_reading_patch { + current_patch.push_str(&line); + current_patch.push('\n'); + } + } + + if !current_patch.is_empty() { + patches.push(current_patch); + } + + Ok(()) + } } impl PatchsetParser for MboxPatchsetParser { @@ -40,151 +92,105 @@ impl PatchsetParser for MboxPatchsetParser { } if self.fs.exists(cover_letter_path) && self.fs.is_file(cover_letter_path) { - extract_patches(&*self.fs, cover_letter_path, &mut patches)?; + Self::extract_patches(&*self.fs, cover_letter_path, &mut patches)?; } - extract_patches(&*self.fs, patchset_path, &mut patches)?; + Self::extract_patches(&*self.fs, patchset_path, &mut patches)?; Ok(patches) } } -fn extract_patches( - fs: &dyn FileSystemTrait, - mbox_path: &Path, - patches: &mut Vec, -) -> Result<(), String> { - let mut current_patch = String::new(); - let mut is_reading_patch = false; - let mut is_last_line = false; - - let mbox_reader = fs - .open_bufreader(mbox_path) - .map_err(|err| format!("{}: {err}", mbox_path.display()))?; - - for line in mbox_reader.lines() { - let line = line.map_err(|err| format!("{}: {err}", mbox_path.display()))?; - - if line.starts_with("Subject: ") { - is_reading_patch = true; - } else if is_reading_patch && line.trim_end().eq("--") { - is_last_line = true; - } else if is_last_line { - current_patch.push_str(&line); - current_patch.push('\n'); - - let mut patch_to_add = String::new(); - swap(&mut patch_to_add, &mut current_patch); - patches.push(patch_to_add); - - is_reading_patch = false; - is_last_line = false; - } else if is_reading_patch && line.trim_end().eq("From git@z Thu Jan 1 00:00:00 1970") { - let mut patch_to_add = String::new(); - swap(&mut patch_to_add, &mut current_patch); - patches.push(patch_to_add); - - is_reading_patch = false; - } - - if is_reading_patch { - current_patch.push_str(&line); - current_patch.push('\n'); +pub struct PatchsetParserService; + +impl PatchsetParserService { + /// Splits a raw patch string into `(cover, diff)` at the first `\n---\n` separator. + /// + /// Everything before (and including) the separator line is the cover; everything + /// after is the diff. If there is no separator, the entire input is returned as the + /// cover with an empty diff slice. + pub fn split_cover(patch: &str) -> (&str, &str) { + let mut cover: &str = patch; + let mut diff: &str = ""; + + if let Some(cover_end) = patch.find("\n---\n") { + cover = &patch[..cover_end + 1]; + diff = &patch[cover_end + 5..]; } - } - - if !current_patch.is_empty() { - patches.push(current_patch); - } - - Ok(()) -} -/// Splits a raw patch string into `(cover, diff)` at the first `\n---\n` separator. -/// -/// Everything before (and including) the separator line is the cover; everything -/// after is the diff. If there is no separator, the entire input is returned as the -/// cover with an empty diff slice. -pub fn split_cover(patch: &str) -> (&str, &str) { - let mut cover: &str = patch; - let mut diff: &str = ""; - - if let Some(cover_end) = patch.find("\n---\n") { - cover = &patch[..cover_end + 1]; - diff = &patch[cover_end + 5..]; + (cover, diff) } - (cover, diff) -} + /// Generates a reply template from a raw patch string, quoting the body and + /// prefixing the subject with `"Re: "`. + pub fn generate_reply_template(patch_contents: &str) -> String { + let mut reply_template = String::new(); + let mut patch_lines_iterator = patch_contents.lines(); + + for line in patch_lines_iterator.by_ref() { + let mut line_to_push = String::new(); + + if line.starts_with("Subject: ") { + line_to_push = line.replace("Subject: ", "Subject: Re: ") + "\n"; + } else if line.starts_with("From: ") + || line.starts_with("Date: ") + || line.starts_with("Message-Id: ") + { + continue; + } else if !line.trim().is_empty() { + line_to_push = format!("{line}\n"); + } else if line.trim().is_empty() && !reply_template.is_empty() { + reply_template.push('\n'); + break; + } -/// Generates a reply template from a raw patch string, quoting the body and -/// prefixing the subject with `"Re: "`. -pub fn generate_reply_template(patch_contents: &str) -> String { - let mut reply_template = String::new(); - let mut patch_lines_iterator = patch_contents.lines(); - - for line in patch_lines_iterator.by_ref() { - let mut line_to_push = String::new(); - - if line.starts_with("Subject: ") { - line_to_push = line.replace("Subject: ", "Subject: Re: ") + "\n"; - } else if line.starts_with("From: ") - || line.starts_with("Date: ") - || line.starts_with("Message-Id: ") - { - continue; - } else if !line.trim().is_empty() { - line_to_push = format!("{line}\n"); - } else if line.trim().is_empty() && !reply_template.is_empty() { - reply_template.push('\n'); - break; + reply_template.push_str(&line_to_push); } - reply_template.push_str(&line_to_push); - } + for line in patch_lines_iterator { + reply_template.push_str(&format!("> {line}\n")); + } - for line in patch_lines_iterator { - reply_template.push_str(&format!("> {line}\n")); + reply_template } - reply_template -} - -/// Extracts the `git send-email` command from a lore patch HTML page and returns -/// a [`ShellCommand`] ready to invoke, with `reply_path` appended as the last -/// argument. -pub fn extract_git_reply_command( - patch_html: &str, - git_send_email_options: &str, - reply_path: &str, -) -> ShellCommand { - let mut args: Vec = vec!["send-email".to_string()]; - - for option in git_send_email_options.split_whitespace() { - args.push(option.to_string()); - } + /// Extracts the `git send-email` command from a lore patch HTML page and returns + /// a [`ShellCommand`] ready to invoke, with `reply_path` appended as the last + /// argument. + pub fn extract_git_reply_command( + patch_html: &str, + git_send_email_options: &str, + reply_path: &str, + ) -> ShellCommand { + let mut args: Vec = vec!["send-email".to_string()]; + + for option in git_send_email_options.split_whitespace() { + args.push(option.to_string()); + } - static RE_FULL_GIT_COMMAND: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?s)git-send-email\(1\):(.*?)/path/to/YOUR_REPLY"#) - .expect("valid full git command regex") - }); + static RE_FULL_GIT_COMMAND: LazyLock = LazyLock::new(|| { + Regex::new(r#"(?s)git-send-email\(1\):(.*?)/path/to/YOUR_REPLY"#) + .expect("valid full git command regex") + }); - static RE_LONG_OPTIONS: LazyLock = - LazyLock::new(|| Regex::new(r"--[^\s=]+=[^\s]+").expect("valid long options regex")); + static RE_LONG_OPTIONS: LazyLock = + LazyLock::new(|| Regex::new(r"--[^\s=]+=[^\s]+").expect("valid long options regex")); - if let Some(capture) = RE_FULL_GIT_COMMAND.captures(patch_html) { - if let Some(full_git_command_match) = capture.get(1) { - for long_option_match in RE_LONG_OPTIONS.find_iter(full_git_command_match.as_str()) { - args.push(long_option_match.as_str().to_string()); + if let Some(capture) = RE_FULL_GIT_COMMAND.captures(patch_html) { + if let Some(full_git_command_match) = capture.get(1) { + for long_option_match in RE_LONG_OPTIONS.find_iter(full_git_command_match.as_str()) + { + args.push(long_option_match.as_str().to_string()); + } } } - } - args.push(reply_path.to_string()); + args.push(reply_path.to_string()); - ShellCommand { - program: "git".to_string(), - args, + ShellCommand { + program: "git".to_string(), + args, + } } } @@ -281,7 +287,7 @@ mod tests { #[test] fn split_cover_finds_separator() { let raw = "Subject: test\nTo: list\n\nBody text.\n---\ndiff --git a/foo b/foo"; - let (cover, diff) = split_cover(raw); + let (cover, diff) = PatchsetParserService::split_cover(raw); assert!(cover.contains("Body text.")); assert!(diff.contains("diff --git")); } @@ -289,7 +295,7 @@ mod tests { #[test] fn split_cover_returns_full_patch_when_no_separator() { let raw = "Subject: no diff here\nBody only."; - let (cover, diff) = split_cover(raw); + let (cover, diff) = PatchsetParserService::split_cover(raw); assert_eq!(raw, cover); assert!(diff.is_empty()); } @@ -305,7 +311,10 @@ mod tests { ) .unwrap(); - assert_eq!(expected, generate_reply_template(&patch_sample)); + assert_eq!( + expected, + PatchsetParserService::generate_reply_template(&patch_sample) + ); } #[test] @@ -330,8 +339,11 @@ mod tests { ], }; - let result = - extract_git_reply_command(&patch_html, "--dry-run --suppress-cc=all", reply_path); + let result = PatchsetParserService::extract_git_reply_command( + &patch_html, + "--dry-run --suppress-cc=all", + reply_path, + ); assert!( commands_eq(&expected, &result), diff --git a/src/render/mod.rs b/src/render/mod.rs index 5938be4..de179b4 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -17,7 +17,7 @@ use tracing::{event, Level}; use crate::{ infrastructure::shell::ShellTrait, - lore::infrastructure::patchset_parser::split_cover, + lore::infrastructure::patchset_parser::PatchsetParserService, render::{cover_renderer::CoverRendererService, patch_renderer::PatchRendererService}, }; @@ -41,7 +41,7 @@ impl RenderServiceApi for ShellRenderService { let mut previews = Vec::with_capacity(request.raw_patches.len()); for raw_patch in request.raw_patches { let raw_patch_expanded = raw_patch.replace('\t', " "); - let (raw_cover, raw_diff) = split_cover(&raw_patch_expanded); + let (raw_cover, raw_diff) = PatchsetParserService::split_cover(&raw_patch_expanded); let rendered_cover = match CoverRendererService::render_cover(shell, raw_cover, &request.cover_renderer) { From ff93bc7e89584f7cf621cc3047e917a14ef2a95f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 19:51:19 -0300 Subject: [PATCH 29/61] refactor(app): group remaining app, terminal and kw helpers into services Signed-off-by: lorenzoberts --- src/app/actor.rs | 12 ++- src/app/dependencies.rs | 154 ++++++++++++++++------------- src/app/loading.rs | 8 +- src/app/screens/details_actions.rs | 19 ++-- src/app/screens/kw_ops.rs | 10 +- src/app/view_model.rs | 6 +- src/kw/actor/mod.rs | 7 +- src/kw/argv.rs | 8 +- src/kw/log_scan.rs | 23 ++--- src/kw/status.rs | 46 +++++---- src/main.rs | 4 +- src/terminal/session.rs | 46 ++++----- 12 files changed, 188 insertions(+), 155 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 009d342..ccc1125 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -11,13 +11,13 @@ //! actor is spawned. use std::{ops::ControlFlow, time::Duration}; -use color_eyre::{eyre::eyre, Result}; +use color_eyre::{eyre::eyre, Report, Result}; use tokio::{spawn, sync::mpsc, sync::watch, time::MissedTickBehavior}; use crate::{ app::{ handle::AppHandle, - loading::{terminal_error, TerminalLoadingIndicator}, + loading::TerminalLoadingIndicator, popup::{AppPopup, ConfirmAction}, screens::CurrentScreen, App, @@ -27,7 +27,7 @@ use crate::{ errors::KwError, status::{KwJobStatus, KwStatusSnapshot}, }, - terminal::{handle::TerminalHandle, messages::TerminalFrame}, + terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, ui::handle::UiHandle, }; @@ -102,7 +102,7 @@ impl AppActor { self.terminal_handle .draw(TerminalFrame::Main(Box::new(scene))) .await - .map_err(terminal_error)?; + .map_err(Self::convert_terminal_error)?; } let tail_while_running = Self::should_tail_kw_ops(&self.app); @@ -193,6 +193,10 @@ impl AppActor { } } } + + fn convert_terminal_error(error: TerminalError) -> Report { + eyre!("{error}") + } } impl AppActor { diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 8db6caf..65afca9 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -8,76 +8,80 @@ use crate::{ render_prefs::PatchRenderer, }; -/// Verifies required and optional external binaries before the terminal starts. -/// -/// A missing `b4` is a hard failure; all other missing binaries only emit -/// warnings — including `kw`, and including an unverifiable kw version, -/// since kw's own VERSION file is stale upstream (it reports `beta-0.9` -/// even at the 0.10 tag). This keeps fatal startup failures out of -/// terminal raw mode. -pub(crate) fn check_external_deps( - env: &dyn EnvTrait, - shell: &dyn ShellTrait, - config: &ConfigSnapshot, -) -> Result<(), AppError> { - if !env.which("b4") { - event!( - Level::ERROR, - "b4 is not installed, patchsets cannot be downloaded" - ); - return Err(AppError::Dependencies( - "b4 is not installed; patchsets cannot be downloaded".to_string(), - )); - } +pub(crate) struct DependencyService; + +impl DependencyService { + /// Verifies required and optional external binaries before the terminal starts. + /// + /// A missing `b4` is a hard failure; all other missing binaries only emit + /// warnings — including `kw`, and including an unverifiable kw version, + /// since kw's own VERSION file is stale upstream (it reports `beta-0.9` + /// even at the 0.10 tag). This keeps fatal startup failures out of + /// terminal raw mode. + pub(crate) fn check_external_deps( + env: &dyn EnvTrait, + shell: &dyn ShellTrait, + config: &ConfigSnapshot, + ) -> Result<(), AppError> { + if !env.which("b4") { + event!( + Level::ERROR, + "b4 is not installed, patchsets cannot be downloaded" + ); + return Err(AppError::Dependencies( + "b4 is not installed; patchsets cannot be downloaded".to_string(), + )); + } - if !env.which("git") { - event!(Level::WARN, "git is not installed, send-email won't work"); - } + if !env.which("git") { + event!(Level::WARN, "git is not installed, send-email won't work"); + } - match config.patch_renderer() { - PatchRenderer::Bat => { - if !env.which("bat") { - event!( - Level::WARN, - "bat is not installed, patch rendering will fallback to default" - ); + match config.patch_renderer() { + PatchRenderer::Bat => { + if !env.which("bat") { + event!( + Level::WARN, + "bat is not installed, patch rendering will fallback to default" + ); + } } - } - PatchRenderer::Delta => { - if !env.which("delta") { - event!( - Level::WARN, - "delta is not installed, patch rendering will fallback to default", - ); + PatchRenderer::Delta => { + if !env.which("delta") { + event!( + Level::WARN, + "delta is not installed, patch rendering will fallback to default", + ); + } } - } - PatchRenderer::DiffSoFancy => { - if !env.which("diff-so-fancy") { - event!( - Level::WARN, - "diff-so-fancy is not installed, patch rendering will fallback to default", - ); + PatchRenderer::DiffSoFancy => { + if !env.which("diff-so-fancy") { + event!( + Level::WARN, + "diff-so-fancy is not installed, patch rendering will fallback to default", + ); + } } + _ => {} } - _ => {} - } - let kw = ReadinessService::probe_kw_binary(env, shell); - if !kw.available { - event!( - Level::WARN, - "kw is not installed, kernel build/deploy won't work" - ); - } else if !matches!(kw.check, KwVersionCheck::Meets) { - event!( - Level::WARN, - version = kw.version_line.as_deref().unwrap_or("unknown"), - "could not confirm kw >= 0.10; the build/deploy integration is \ + let kw = ReadinessService::probe_kw_binary(env, shell); + if !kw.available { + event!( + Level::WARN, + "kw is not installed, kernel build/deploy won't work" + ); + } else if !matches!(kw.check, KwVersionCheck::Meets) { + event!( + Level::WARN, + version = kw.version_line.as_deref().unwrap_or("unknown"), + "could not confirm kw >= 0.10; the build/deploy integration is \ verified against kw 0.10 (kw's own VERSION file may be stale)" - ); - } + ); + } - Ok(()) + Ok(()) + } } #[cfg(test)] @@ -118,8 +122,12 @@ mod tests { let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); - let err = - check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()).unwrap_err(); + let err = DependencyService::check_external_deps( + &env, + &shell, + &ConfigState::default().to_snapshot(), + ) + .unwrap_err(); assert!(matches!(err, AppError::Dependencies(_))); } @@ -131,7 +139,11 @@ mod tests { .withf(|name| name == "git") .returning(|_| false); - let result = check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()); + let result = DependencyService::check_external_deps( + &env, + &shell, + &ConfigState::default().to_snapshot(), + ); assert!(result.is_ok()); } @@ -149,7 +161,7 @@ mod tests { .withf(|name| name == "bat") .returning(|_| false); - let result = check_external_deps(&env, &shell, &state.to_snapshot()); + let result = DependencyService::check_external_deps(&env, &shell, &state.to_snapshot()); assert!(result.is_ok()); } @@ -163,7 +175,11 @@ mod tests { // No kw on PATH: the version probe must not spawn anything. shell.expect_execute().times(0); - let result = check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()); + let result = DependencyService::check_external_deps( + &env, + &shell, + &ConfigState::default().to_snapshot(), + ); assert!(result.is_ok()); } @@ -181,7 +197,11 @@ mod tests { }) }); - let result = check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()); + let result = DependencyService::check_external_deps( + &env, + &shell, + &ConfigState::default().to_snapshot(), + ); assert!(result.is_ok()); } diff --git a/src/app/loading.rs b/src/app/loading.rs index d1fdc32..a4f4836 100644 --- a/src/app/loading.rs +++ b/src/app/loading.rs @@ -7,14 +7,14 @@ use std::{ time::Duration, }; -use color_eyre::{eyre::eyre, Report, Result}; +use color_eyre::Result; use tokio::{ runtime::Handle, spawn, task::{block_in_place, JoinHandle}, }; -use crate::terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}; +use crate::terminal::{handle::TerminalHandle, messages::TerminalFrame}; pub(crate) const LOADING_FRAME_INTERVAL: Duration = Duration::from_millis(200); @@ -84,10 +84,6 @@ impl LoadingIndicator for TerminalLoadingIndicator { } } -pub(crate) fn terminal_error(error: TerminalError) -> Report { - eyre!("{error}") -} - #[cfg(test)] mod tests { use crate::terminal::{ diff --git a/src/app/screens/details_actions.rs b/src/app/screens/details_actions.rs index d07f1d2..9df5ed6 100644 --- a/src/app/screens/details_actions.rs +++ b/src/app/screens/details_actions.rs @@ -44,10 +44,6 @@ pub struct PatchsetDetailsState { const LAST_LINE_PADDING: usize = 10; -fn rendered_preview_height(preview: &str) -> usize { - preview.into_text().unwrap_or_default().height() -} - #[derive(Clone, Hash, Eq, PartialEq)] pub enum PatchsetAction { Bookmark, @@ -56,6 +52,10 @@ pub enum PatchsetAction { } impl PatchsetDetailsState { + fn measure_rendered_preview_height(preview: &str) -> usize { + preview.into_text().unwrap_or_default().height() + } + pub fn from_rendered_preview( representative_patch: Patch, details: PatchsetDetails, @@ -123,7 +123,8 @@ impl PatchsetDetailsState { /// Scroll `n` lines down pub fn preview_scroll_down(&mut self, n: usize) { - let number_of_lines = rendered_preview_height(&self.patches_preview[self.preview_index]); + let number_of_lines = + Self::measure_rendered_preview_height(&self.patches_preview[self.preview_index]); if (self.preview_scroll_offset + n) <= number_of_lines { self.preview_scroll_offset += n; } @@ -136,7 +137,8 @@ impl PatchsetDetailsState { /// Scroll to the last line pub fn go_to_last_line(&mut self) { - let number_of_lines = rendered_preview_height(&self.patches_preview[self.preview_index]); + let number_of_lines = + Self::measure_rendered_preview_height(&self.patches_preview[self.preview_index]); self.preview_scroll_offset = number_of_lines.saturating_sub(LAST_LINE_PADDING); } @@ -274,7 +276,10 @@ mod tests { fn rendered_height_accounts_for_rendered_text_projection() { let preview = "\u{1b}[32mrendered line\u{1b}[0m\nsecond line"; - assert_eq!(2, rendered_preview_height(preview)); + assert_eq!( + 2, + PatchsetDetailsState::measure_rendered_preview_height(preview) + ); } #[test] diff --git a/src/app/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index 16bf682..518daeb 100644 --- a/src/app/screens/kw_ops.rs +++ b/src/app/screens/kw_ops.rs @@ -48,7 +48,7 @@ impl KwOpsState { } pub fn extra_arg_tokens(&self) -> Vec { - split_extra_args(&self.extra_args) + Self::split_extra_args(&self.extra_args) } /// Extra args shown in the command preview, including in-progress @@ -59,7 +59,7 @@ impl KwOpsState { } else { self.extra_args.as_str() }; - split_extra_args(raw) + Self::split_extra_args(raw) } pub fn highlight_prev(&mut self) { @@ -99,10 +99,10 @@ impl KwOpsState { pub fn append_edit(&mut self, ch: char) { self.edit_buffer.push(ch); } -} -fn split_extra_args(raw: &str) -> Vec { - raw.split_whitespace().map(str::to_string).collect() + fn split_extra_args(raw: &str) -> Vec { + raw.split_whitespace().map(str::to_string).collect() + } } #[cfg(test)] diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 96bb588..47c2f94 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -18,7 +18,7 @@ use crate::kw::{ argv, readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, remote::{KwRemote, RemoteRefusal}, - status::{deploy_exit_hint, KwJobStatus, KwPhase, KwStatusSnapshot}, + status::{KwJobStatus, KwPhase, KwStatusSnapshot}, }; pub use crate::app::models::view_model::{ @@ -454,7 +454,7 @@ fn format_deploy_command( match remote { Ok(remote) => format!( "kw {}", - argv::KwArgvService::deploy_argv(&remote.endpoint(), reboot, force, extra_args) + argv::KwArgvService::build_deploy_argv(&remote.endpoint(), reboot, force, extra_args) .join(" ") ), Err(_) => "(no remote)".to_string(), @@ -525,7 +525,7 @@ fn format_job_status(job: Option<&KwJobStatus>, cancel_requested: bool) -> Strin exit_code, .. }) => match exit_code { - Some(code) => match deploy_exit_hint(*code) { + Some(code) => match KwJobStatus::find_deploy_exit_hint(*code) { Some(hint) => format!("failed during deploy (exit {code}: {hint})"), None => format!("failed during deploy (exit {code})"), }, diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index c56f2b6..512031e 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -558,7 +558,7 @@ impl KwActor { "deploy-{}.log", chrono::Utc::now().format("%Y%m%d-%H%M%S-%3f") )); - let cmd = ShellCommand::new("kw").args(argv::KwArgvService::deploy_argv( + let cmd = ShellCommand::new("kw").args(argv::KwArgvService::build_deploy_argv( &deploy.remote.endpoint(), deploy.options.reboot, deploy.options.force, @@ -608,7 +608,10 @@ impl KwActor { .and_then(|deploy| deploy.kernelrelease.as_deref()); self.read_job_log(&job.log_path) .map(|log| { - log_scan::LogScanService::deploy_warnings(&log, kernelrelease) + log_scan::LogScanService::collect_deploy_warnings( + &log, + kernelrelease, + ) }) .unwrap_or_default() } else { diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 307da56..52ec1f8 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -119,7 +119,7 @@ impl KwArgvService { /// so build-only extras (shared KwOps field) cannot fail kw deploy's /// getopt. `--force` is omitted entirely when `force` is false rather /// than passing a no-op, because kw has no `--no-force`. - pub fn deploy_argv( + pub fn build_deploy_argv( endpoint: &str, reboot: bool, force: bool, @@ -419,14 +419,14 @@ mod tests { const ENDPOINT: &str = "root@lima-ph-dut.internal:22"; fn deploy(extra: &[&str]) -> Vec { - KwArgvService::deploy_argv(ENDPOINT, false, true, &extras(extra)) + KwArgvService::build_deploy_argv(ENDPOINT, false, true, &extras(extra)) } #[test] fn deploy_argv_without_extras_is_remote_no_reboot_force() { assert_eq!( vec!["deploy", "--remote", ENDPOINT, "--no-reboot", "--force",], - KwArgvService::deploy_argv(ENDPOINT, false, true, &[]) + KwArgvService::build_deploy_argv(ENDPOINT, false, true, &[]) ); } @@ -434,7 +434,7 @@ mod tests { fn deploy_argv_reboot_and_unforced_swap_the_injected_flags() { assert_eq!( vec!["deploy", "--remote", ENDPOINT, "--reboot"], - KwArgvService::deploy_argv(ENDPOINT, true, false, &[]) + KwArgvService::build_deploy_argv(ENDPOINT, true, false, &[]) ); } diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index 75fedd8..c78f41a 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -45,7 +45,7 @@ impl LogScanService { /// did not list that kernel is reported too: kw installs arm64 kernels as /// `Image-`, which Debian's `10_linux` does not pick up, so the /// deployed kernel is never bootable from the menu. - pub fn deploy_warnings(log: &str, kernelrelease: Option<&str>) -> Vec { + pub fn collect_deploy_warnings(log: &str, kernelrelease: Option<&str>) -> Vec { let mut warnings: Vec = Vec::new(); for line in log.lines().map(str::trim) { if DEPLOY_WARNING_MARKERS @@ -266,7 +266,7 @@ make: *** [Makefile:248: __sub-make] Error 2 "update-initramfs: failed for /boot/initrd.img-7.2.0-rc6+ with 1.".to_string(), "GRUB did not list kernel 7.2.0-rc6+".to_string(), ], - LogScanService::deploy_warnings(TINYCONFIG_DEPLOY_LOG, Some("7.2.0-rc6+")) + LogScanService::collect_deploy_warnings(TINYCONFIG_DEPLOY_LOG, Some("7.2.0-rc6+")) ); } @@ -274,13 +274,13 @@ make: *** [Makefile:248: __sub-make] Error 2 fn deploy_warnings_skip_the_grub_check_without_a_release() { assert_eq!( vec!["update-initramfs: failed for /boot/initrd.img-7.2.0-rc6+ with 1.".to_string()], - LogScanService::deploy_warnings(TINYCONFIG_DEPLOY_LOG, None) + LogScanService::collect_deploy_warnings(TINYCONFIG_DEPLOY_LOG, None) ); } #[test] fn clean_deploy_has_no_warnings() { - assert!(LogScanService::deploy_warnings( + assert!(LogScanService::collect_deploy_warnings( CLEAN_DEPLOY_LOG, Some("7.2.0-rc6-phboot-g2a475abe5df2") ) @@ -297,17 +297,18 @@ done "; assert_eq!( vec!["GRUB did not list kernel 7.2.0-rc6".to_string()], - LogScanService::deploy_warnings(log, Some("7.2.0-rc6")) + LogScanService::collect_deploy_warnings(log, Some("7.2.0-rc6")) ); } #[test] fn grub_check_is_silent_when_grub_did_not_run() { // systemd-boot / non-GRUB DUTs never print the GRUB banner. - assert!( - LogScanService::deploy_warnings("==> Starting build: '7.2.0'\n", Some("7.2.0")) - .is_empty() - ); + assert!(LogScanService::collect_deploy_warnings( + "==> Starting build: '7.2.0'\n", + Some("7.2.0") + ) + .is_empty()); } #[test] @@ -322,14 +323,14 @@ done ==> ERROR: module not found: 'f' kw was unable to set up the first boot "; - let warnings = LogScanService::deploy_warnings(log, None); + let warnings = LogScanService::collect_deploy_warnings(log, None); assert_eq!(MAX_DEPLOY_WARNINGS, warnings.len()); assert_eq!("==> ERROR: module not found: 'a'", warnings[0]); assert_eq!("==> ERROR: module not found: 'e'", warnings[4]); // The unlisted-kernel warning survives the cap. let log = format!("{log}Generating grub configuration file ...\n"); - let warnings = LogScanService::deploy_warnings(&log, Some("7.2.0")); + let warnings = LogScanService::collect_deploy_warnings(&log, Some("7.2.0")); assert_eq!(MAX_DEPLOY_WARNINGS, warnings.len()); assert_eq!("GRUB did not list kernel 7.2.0", warnings[4]); } diff --git a/src/kw/status.rs b/src/kw/status.rs index 89d7274..53323ff 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -38,7 +38,7 @@ pub enum KwJobStatus { branch: String, log_path: PathBuf, /// Known deploy failures kw exited 0 through (see - /// [`crate::kw::log_scan::LogScanService::deploy_warnings`]). Always empty for a + /// [`crate::kw::log_scan::LogScanService::collect_deploy_warnings`]). Always empty for a /// build-only job. warnings: Vec, }, @@ -102,23 +102,23 @@ impl KwJobStatus { | Self::Cancelled { log_path, .. } => Some(log_path), } } -} -/// Human-readable hint for a known `kw deploy` exit code. -/// Unknown codes return `None` so the UI can still show the raw number. -/// 68 can still surface with `--force`: force skips the prompt, not the -/// initramfs errors. -pub fn deploy_exit_hint(code: i32) -> Option<&'static str> { - Some(match code { - 2 => "kernel image not found", - 22 => "invalid option or kernel name", - 68 => "initramfs generation reported errors", - 95 => "unsupported bootloader", - 101 => "SSH unreachable after setup", - 103 => "passwordless root SSH setup failed", - 124 | 125 => "deploy cancelled, no valid kernel image, or not a kernel root", - _ => return None, - }) + /// Human-readable hint for a known `kw deploy` exit code. + /// Unknown codes return `None` so the UI can still show the raw number. + /// 68 can still surface with `--force`: force skips the prompt, not the + /// initramfs errors. + pub fn find_deploy_exit_hint(code: i32) -> Option<&'static str> { + Some(match code { + 2 => "kernel image not found", + 22 => "invalid option or kernel name", + 68 => "initramfs generation reported errors", + 95 => "unsupported bootloader", + 101 => "SSH unreachable after setup", + 103 => "passwordless root SSH setup failed", + 124 | 125 => "deploy cancelled, no valid kernel image, or not a kernel root", + _ => return None, + }) + } } #[cfg(test)] @@ -207,15 +207,19 @@ mod tests { ), ]; for (code, hint) in cases { - assert_eq!(Some(hint), deploy_exit_hint(code), "code {code}"); + assert_eq!( + Some(hint), + KwJobStatus::find_deploy_exit_hint(code), + "code {code}" + ); } } #[test] fn deploy_exit_hint_leaves_unknown_codes_unnamed() { // Unknown codes have no hint. - assert_eq!(None, deploy_exit_hint(30)); - assert_eq!(None, deploy_exit_hint(1)); - assert_eq!(None, deploy_exit_hint(0)); + assert_eq!(None, KwJobStatus::find_deploy_exit_hint(30)); + assert_eq!(None, KwJobStatus::find_deploy_exit_hint(1)); + assert_eq!(None, KwJobStatus::find_deploy_exit_hint(0)); } } diff --git a/src/main.rs b/src/main.rs index c9a8858..2f8fa06 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,7 @@ mod render_prefs; mod terminal; mod ui; -use app::{actor::AppActor, dependencies::check_external_deps, App}; +use app::{actor::AppActor, dependencies::DependencyService, App}; use clap::Parser; use cli::Cli; use color_eyre::{eyre::eyre, Result}; @@ -79,7 +79,7 @@ async fn main() -> Result<()> { ControlFlow::Continue(()) => {} } - check_external_deps(&env, &OsShell, &config)?; + DependencyService::check_external_deps(&env, &OsShell, &config)?; let config_handle = ConfigActor::spawn(config_state, config_repo); let terminal_handle = TerminalActor::spawn(Box::new(CrosstermTerminalSession::new(init()?))); diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 622319d..9752d15 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -51,6 +51,28 @@ impl CrosstermTerminalSession { _ => None, } } + + fn wait_for_key_press_from_session( + session: &mut dyn TerminalSessionApi, + key: KeyCode, + timeout: Duration, + ) -> TerminalResult { + let started_at = Instant::now(); + + while started_at.elapsed() < timeout { + let elapsed = started_at.elapsed(); + let remaining = timeout.saturating_sub(elapsed); + let poll_timeout = remaining.min(Duration::from_millis(16)); + + if let Some(TerminalEvent::Key(input)) = session.poll_event(poll_timeout)? { + if input.code == key { + return Ok(true); + } + } + } + + Ok(false) + } } impl TerminalSessionApi for CrosstermTerminalSession { @@ -93,7 +115,7 @@ impl TerminalSessionApi for CrosstermTerminalSession { } fn wait_for_key_press(&mut self, key: KeyCode, timeout: Duration) -> TerminalResult { - wait_for_key_press_from_session(self, key, timeout) + Self::wait_for_key_press_from_session(self, key, timeout) } fn size(&self) -> TerminalResult<(u16, u16)> { @@ -112,28 +134,6 @@ impl TerminalSessionApi for CrosstermTerminalSession { } } -fn wait_for_key_press_from_session( - session: &mut dyn TerminalSessionApi, - key: KeyCode, - timeout: Duration, -) -> TerminalResult { - let started_at = Instant::now(); - - while started_at.elapsed() < timeout { - let elapsed = started_at.elapsed(); - let remaining = timeout.saturating_sub(elapsed); - let poll_timeout = remaining.min(Duration::from_millis(16)); - - if let Some(TerminalEvent::Key(input)) = session.poll_event(poll_timeout)? { - if input.code == key { - return Ok(true); - } - } - } - - Ok(false) -} - #[cfg(test)] mod tests { use ratatui::crossterm::event::{ From c4473f65692ac50112cd11cd8f7d8545b353b19f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 20:13:28 -0300 Subject: [PATCH 30/61] refactor: split public and private impl blocks Signed-off-by: lorenzoberts --- src/app/actions/apply.rs | 2 + src/app/actions/reviewed_reply.rs | 2 + src/app/actor.rs | 2 + src/app/flows/details_actions.rs | 26 +-- src/app/flows/kw_ops.rs | 144 ++++++++--------- src/app/flows/open_patchset.rs | 2 + src/app/mod.rs | 77 ++++----- src/app/screens/details_actions.rs | 10 +- src/app/screens/kw_ops.rs | 2 + src/app/screens/mail_list.rs | 26 +-- src/config/actor.rs | 5 + src/config/handle.rs | 2 + src/config/service.rs | 42 ++--- src/config/state.rs | 112 ++++++------- .../logging/multi_log_file_writer.rs | 2 + src/input/actor.rs | 2 + src/input/handle.rs | 2 + src/input/mapper.rs | 2 + src/kw/actor/mod.rs | 2 + src/kw/argv.rs | 2 + src/kw/handle.rs | 2 + src/kw/history.rs | 2 + src/kw/log_scan.rs | 2 + src/kw/readiness.rs | 148 +++++++++--------- src/kw/remote.rs | 3 + src/lore/application/actor.rs | 2 + src/lore/application/handle.rs | 2 + src/lore/application/service.rs | 2 + src/lore/domain/patch.rs | 22 +-- src/lore/domain/patchset.rs | 2 + src/lore/infrastructure/patchset_fetcher.rs | 2 + src/lore/infrastructure/patchset_parser.rs | 2 + src/lore/infrastructure/persistence.rs | 2 + src/render/actor.rs | 2 + src/render/cover_renderer.rs | 2 + src/render/handle.rs | 2 + src/render/patch_renderer.rs | 28 ++-- src/terminal/actor.rs | 2 + src/terminal/handle.rs | 2 + src/terminal/session.rs | 2 + src/ui/actor.rs | 2 + src/ui/core.rs | 2 + src/ui/handle.rs | 2 + src/ui/loading_screen.rs | 20 +-- src/ui/painter.rs | 34 ++-- src/ui/screens/details.rs | 30 ++-- src/ui/screens/kw_ops.rs | 84 +++++----- src/ui/screens/popup.rs | 2 + 48 files changed, 488 insertions(+), 387 deletions(-) diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index 0bb2240..0ff36c4 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -71,7 +71,9 @@ impl ApplyPatchsetService { } } } +} +impl ApplyPatchsetService { fn validate_kernel_tree<'a>( fs: &dyn FileSystemTrait, config: &'a ConfigSnapshot, diff --git a/src/app/actions/reviewed_reply.rs b/src/app/actions/reviewed_reply.rs index 9f01218..af24855 100644 --- a/src/app/actions/reviewed_reply.rs +++ b/src/app/actions/reviewed_reply.rs @@ -86,7 +86,9 @@ impl ReviewedReplyService { Ok(ReviewedReplyResult::Completed { successful_indexes }) } +} +impl ReviewedReplyService { fn record_successful_reply_indexes( shell: &dyn ShellTrait, successful_indexes: &mut HashSet, diff --git a/src/app/actor.rs b/src/app/actor.rs index ccc1125..5316bf6 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -73,7 +73,9 @@ impl AppActor { }; AppHandle::new(spawn(actor.run())) } +} +impl AppActor { async fn run(mut self) -> Result<()> { tracing::info!("app actor started"); tracing::info!("app actor initialized"); diff --git a/src/app/flows/details_actions.rs b/src/app/flows/details_actions.rs index d907697..4c514dd 100644 --- a/src/app/flows/details_actions.rs +++ b/src/app/flows/details_actions.rs @@ -111,18 +111,6 @@ impl App { Ok(()) } - async fn count_preview_scroll_lines( - amount: ScrollAmount, - terminal_handle: &TerminalHandle, - ) -> Result { - let (_, height) = terminal_handle.size().await?; - Ok(match amount { - ScrollAmount::Line => 1, - ScrollAmount::HalfPage => height as usize / 2, - ScrollAmount::Page => height as usize, - }) - } - pub fn build_details_help_popup() -> AppPopup { AppPopup::help() .title("Patchset Details and Actions") @@ -148,3 +136,17 @@ impl App { .build() } } + +impl App { + async fn count_preview_scroll_lines( + amount: ScrollAmount, + terminal_handle: &TerminalHandle, + ) -> Result { + let (_, height) = terminal_handle.size().await?; + Ok(match amount { + ScrollAmount::Line => 1, + ScrollAmount::HalfPage => height as usize / 2, + ScrollAmount::Page => height as usize, + }) + } +} diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 321c546..09e1947 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -95,27 +95,6 @@ impl App { Self::assign_log_tail(ops, text) } - fn map_tail_read(result: Result) -> String { - match result { - Ok(text) => text, - Err(FileSystemError::IoError(error)) - if error.kind() == std::io::ErrorKind::NotFound => - { - String::new() - } - Err(error) => format!("(could not read log: {error})"), - } - } - - fn assign_log_tail(ops: &mut KwOpsState, text: String) -> bool { - if ops.log_tail == text { - false - } else { - ops.log_tail = text; - true - } - } - /// Projects a kw snapshot into App state and releases the optimistic /// start lock once the actor has left Idle. pub(crate) fn apply_kw_snapshot(&mut self, snapshot: KwStatusSnapshot) { @@ -127,15 +106,6 @@ impl App { self.state.kw.status = Some(snapshot); } - fn is_snapshot_job_terminal(job: &KwJobStatus) -> bool { - matches!( - job, - KwJobStatus::Succeeded { .. } - | KwJobStatus::Failed { .. } - | KwJobStatus::Cancelled { .. } - ) - } - /// Re-probe deploy-alone after a job ends so the (d) label tracks the /// in-session build record instead of staying on the pre-build snapshot. pub(crate) async fn apply_kw_snapshot_refreshing_readiness( @@ -311,6 +281,79 @@ impl App { Ok(()) } + /// Records that the user confirmed boot-once and re-issues the pending + /// StartDeploy / StartBuildThenDeploy against that acknowledgement. + pub(crate) async fn resume_pending_deploy(&mut self) -> Result<()> { + let Some(ops) = self.state.kw.ops.as_mut() else { + return Ok(()); + }; + ops.boot_once_acknowledged = true; + let Some(kind) = ops.pending_deploy.take() else { + return Ok(()); + }; + match kind { + DeployStartKind::Deploy => self.start_job(KwStartKind::Deploy).await, + DeployStartKind::BuildThenDeploy => self.start_job(KwStartKind::BuildThenDeploy).await, + } + } + + /// Drops a deploy start that was waiting on the boot-once confirm popup. + pub(crate) fn clear_pending_deploy(&mut self) { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.pending_deploy = None; + } + } + + pub fn build_kw_ops_help_popup() -> AppPopup { + AppPopup::help() + .title("Kw operations") + .description( + "Start a kw build and/or remote deploy on the configured target kernel tree.", + ) + .keybind("ESC / q", "Return to patchset details") + .keybind("j/k", "Move between branch and extra arguments") + .keybind("e / ENTER", "Edit the focused field") + .keybind("b", "Start build") + .keybind("d", "Start deploy") + .keybind("D", "Start build then deploy") + .keybind("c", "Cancel the running job") + .keybind("r", "Restore the previous branch") + .keybind("?", "Show this help screen") + .build() + } +} + +impl App { + fn map_tail_read(result: Result) -> String { + match result { + Ok(text) => text, + Err(FileSystemError::IoError(error)) + if error.kind() == std::io::ErrorKind::NotFound => + { + String::new() + } + Err(error) => format!("(could not read log: {error})"), + } + } + + fn assign_log_tail(ops: &mut KwOpsState, text: String) -> bool { + if ops.log_tail == text { + false + } else { + ops.log_tail = text; + true + } + } + + fn is_snapshot_job_terminal(job: &KwJobStatus) -> bool { + matches!( + job, + KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } + ) + } + async fn start_job(&mut self, kind: KwStartKind) -> Result<()> { if self.is_job_busy() { return Ok(()); @@ -518,47 +561,6 @@ impl App { .as_ref() .is_some_and(|ops| ops.start_requested) } - - /// Records that the user confirmed boot-once and re-issues the pending - /// StartDeploy / StartBuildThenDeploy against that acknowledgement. - pub(crate) async fn resume_pending_deploy(&mut self) -> Result<()> { - let Some(ops) = self.state.kw.ops.as_mut() else { - return Ok(()); - }; - ops.boot_once_acknowledged = true; - let Some(kind) = ops.pending_deploy.take() else { - return Ok(()); - }; - match kind { - DeployStartKind::Deploy => self.start_job(KwStartKind::Deploy).await, - DeployStartKind::BuildThenDeploy => self.start_job(KwStartKind::BuildThenDeploy).await, - } - } - - /// Drops a deploy start that was waiting on the boot-once confirm popup. - pub(crate) fn clear_pending_deploy(&mut self) { - if let Some(ops) = self.state.kw.ops.as_mut() { - ops.pending_deploy = None; - } - } - - pub fn build_kw_ops_help_popup() -> AppPopup { - AppPopup::help() - .title("Kw operations") - .description( - "Start a kw build and/or remote deploy on the configured target kernel tree.", - ) - .keybind("ESC / q", "Return to patchset details") - .keybind("j/k", "Move between branch and extra arguments") - .keybind("e / ENTER", "Edit the focused field") - .keybind("b", "Start build") - .keybind("d", "Start deploy") - .keybind("D", "Start build then deploy") - .keybind("c", "Cancel the running job") - .keybind("r", "Restore the previous branch") - .keybind("?", "Show this help screen") - .build() - } } #[cfg(test)] diff --git a/src/app/flows/open_patchset.rs b/src/app/flows/open_patchset.rs index 8377a1e..a5aa856 100644 --- a/src/app/flows/open_patchset.rs +++ b/src/app/flows/open_patchset.rs @@ -24,7 +24,9 @@ impl App { } } } +} +impl App { fn resolve_open_patchset_result( origin: CurrentScreen, result: Result, diff --git a/src/app/mod.rs b/src/app/mod.rs index db43fde..e7e4674 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -328,6 +328,46 @@ impl App { Ok(()) } + /// Opens the edit-config screen from the current configuration snapshot. + pub fn init_edit_config(&mut self) { + self.state.config_state.edit_config = Some(EditConfigState::new(&self.state.config)); + } + + pub fn reset_edit_config(&mut self) { + self.state.config_state.edit_config = None; + } + + /// Applies edited values from [`ConfigUiState::edit_config`] into [`AppState::config`]. + pub async fn consolidate_edit_config(&mut self) -> Result<()> { + if let Some(edit_config) = &self.state.config_state.edit_config { + debug!("validating and applying config update"); + let draft = edit_config.to_update_draft(); + let snapshot = self + .services + .config + .validate_and_apply(draft) + .await + .map_err(|e| eyre!("{e:#?}"))?; + self.state.config = snapshot; + info!("configuration updated and persisted"); + } + Ok(()) + } + + pub fn set_current_screen(&mut self, new_current_screen: CurrentScreen) { + self.state.navigation.current_screen = new_current_screen; + } + + /// Projects the current [`AppState`] into an owned [`AppViewModel`]. + /// + /// This is the primary way for the orchestration layer to hand off + /// presentation data to the UI actor without exposing raw `AppState`. + pub fn present(&self) -> AppViewModel { + view_model::project_state(&self.state) + } +} + +impl App { async fn sync_patchset_bookmark(&mut self) -> Result<()> { let details = self .state @@ -526,43 +566,6 @@ impl App { } } - /// Opens the edit-config screen from the current configuration snapshot. - pub fn init_edit_config(&mut self) { - self.state.config_state.edit_config = Some(EditConfigState::new(&self.state.config)); - } - - pub fn reset_edit_config(&mut self) { - self.state.config_state.edit_config = None; - } - - /// Applies edited values from [`ConfigUiState::edit_config`] into [`AppState::config`]. - pub async fn consolidate_edit_config(&mut self) -> Result<()> { - if let Some(edit_config) = &self.state.config_state.edit_config { - debug!("validating and applying config update"); - let draft = edit_config.to_update_draft(); - let snapshot = self - .services - .config - .validate_and_apply(draft) - .await - .map_err(|e| eyre!("{e:#?}"))?; - self.state.config = snapshot; - info!("configuration updated and persisted"); - } - Ok(()) - } - - pub fn set_current_screen(&mut self, new_current_screen: CurrentScreen) { - self.state.navigation.current_screen = new_current_screen; - } - - /// Projects the current [`AppState`] into an owned [`AppViewModel`]. - /// - /// This is the primary way for the orchestration layer to hand off - /// presentation data to the UI actor without exposing raw `AppState`. - pub fn present(&self) -> AppViewModel { - view_model::project_state(&self.state) - } fn is_patchset_action_selected( details: &PatchsetDetailsState, action: &PatchsetAction, diff --git a/src/app/screens/details_actions.rs b/src/app/screens/details_actions.rs index 9df5ed6..5aba6f9 100644 --- a/src/app/screens/details_actions.rs +++ b/src/app/screens/details_actions.rs @@ -52,10 +52,6 @@ pub enum PatchsetAction { } impl PatchsetDetailsState { - fn measure_rendered_preview_height(preview: &str) -> usize { - preview.into_text().unwrap_or_default().height() - } - pub fn from_rendered_preview( representative_patch: Patch, details: PatchsetDetails, @@ -221,6 +217,12 @@ impl PatchsetDetailsState { } } +impl PatchsetDetailsState { + fn measure_rendered_preview_height(preview: &str) -> usize { + preview.into_text().unwrap_or_default().height() + } +} + #[cfg(test)] mod tests { use std::collections::{HashMap, HashSet}; diff --git a/src/app/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index 518daeb..b316815 100644 --- a/src/app/screens/kw_ops.rs +++ b/src/app/screens/kw_ops.rs @@ -99,7 +99,9 @@ impl KwOpsState { pub fn append_edit(&mut self, ch: char) { self.edit_buffer.push(ch); } +} +impl KwOpsState { fn split_extra_args(raw: &str) -> Vec { raw.split_whitespace().map(str::to_string).collect() } diff --git a/src/app/screens/mail_list.rs b/src/app/screens/mail_list.rs index 8911930..66cc72c 100644 --- a/src/app/screens/mail_list.rs +++ b/src/app/screens/mail_list.rs @@ -50,18 +50,6 @@ impl MailingListSelectionState { self.process_possible_mailing_lists(); } - fn process_possible_mailing_lists(&mut self) { - let possible_mailing_lists = self - .mailing_lists - .iter() - .filter(|mailing_list| mailing_list.name().starts_with(&self.target_list)) - .cloned() - .collect::>(); - - self.possible_mailing_lists = possible_mailing_lists; - self.highlighted_list_index = 0; - } - pub fn highlight_below_list(&mut self) { if self.highlighted_list_index + 1 < self.possible_mailing_lists.len() { self.highlighted_list_index += 1; @@ -83,6 +71,20 @@ impl MailingListSelectionState { } } +impl MailingListSelectionState { + fn process_possible_mailing_lists(&mut self) { + let possible_mailing_lists = self + .mailing_lists + .iter() + .filter(|mailing_list| mailing_list.name().starts_with(&self.target_list)) + .cloned() + .collect::>(); + + self.possible_mailing_lists = possible_mailing_lists; + self.highlighted_list_index = 0; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config/actor.rs b/src/config/actor.rs index 5a9afcc..97a1a2e 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -57,7 +57,12 @@ where } tracing::info!("config actor stopped"); } +} +impl ConfigActor +where + FS: FileSystemTrait + Send + Sync + 'static, +{ fn handle_message(&mut self, message: ConfigMessage) -> ControlFlow<()> { let message_name = message.name(); tracing::debug!(message = message_name, "config request received"); diff --git a/src/config/handle.rs b/src/config/handle.rs index a0ae5dd..a3cf763 100644 --- a/src/config/handle.rs +++ b/src/config/handle.rs @@ -39,7 +39,9 @@ impl ConfigHandle { pub async fn shutdown(&self) { self.tx.send(ConfigMessage::Shutdown).await.ok(); } +} +impl ConfigHandle { async fn request_result( &self, build_message: impl FnOnce(oneshot::Sender>) -> ConfigMessage, diff --git a/src/config/service.rs b/src/config/service.rs index ee40a68..f59d39b 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -29,26 +29,6 @@ impl ConfigService { Ok((state, repo)) } - fn load_initial_state( - env: &dyn EnvTrait, - repo: &JsonConfigRepository, - ) -> ConfigState { - let path = repo.config_path(); - let fs = repo.fs(); - if fs.is_file(Path::new(path)) { - match fs.read_to_string(Path::new(path)) { - Ok(file_contents) => match from_str(&file_contents) { - Ok(config) => return config, - Err(e) => eprintln!("Failed to parse config file {path}: {e}"), - }, - Err(e) => { - eprintln!("Failed to read config file {path}: {e}"); - } - } - } - ConfigState::new_with_defaults(env) - } - pub(crate) fn ensure_directories( state: &ConfigState, fs: &dyn FileSystemTrait, @@ -201,6 +181,28 @@ impl ConfigService { target_kernel_tree, }) } +} + +impl ConfigService { + fn load_initial_state( + env: &dyn EnvTrait, + repo: &JsonConfigRepository, + ) -> ConfigState { + let path = repo.config_path(); + let fs = repo.fs(); + if fs.is_file(Path::new(path)) { + match fs.read_to_string(Path::new(path)) { + Ok(file_contents) => match from_str(&file_contents) { + Ok(config) => return config, + Err(e) => eprintln!("Failed to parse config file {path}: {e}"), + }, + Err(e) => { + eprintln!("Failed to read config file {path}: {e}"); + } + } + } + ConfigState::new_with_defaults(env) + } fn reject_unknown_kernel_tree(raw: &str, current: &ConfigState) -> ConfigError { let mut keys: Vec = current.kernel_trees.keys().cloned().collect(); diff --git a/src/config/state.rs b/src/config/state.rs index 764a758..6e04482 100644 --- a/src/config/state.rs +++ b/src/config/state.rs @@ -64,6 +64,63 @@ impl ConfigState { Self::defaults_from_home(&home) } + /// Merges validated field updates from the edit-config flow. + pub fn apply_update(&mut self, u: &ValidatedConfigUpdate) { + if let Some(page_size) = u.page_size { + self.set_page_size(page_size); + } + if let Some(ref cache_dir) = u.cache_dir { + self.set_cache_dir(cache_dir.clone()); + } + if let Some(ref data_dir) = u.data_dir { + self.set_data_dir(data_dir.clone()); + } + if let Some(ref git_send_email_options) = u.git_send_email_option { + self.set_git_send_email_option(git_send_email_options.clone()); + } + if let Some(ref git_am_options) = u.git_am_option { + self.set_git_am_option(git_am_options.clone()); + } + if let Some(patch_renderer) = u.patch_renderer { + self.set_patch_renderer(patch_renderer); + } + if let Some(cover_renderer) = u.cover_renderer { + self.set_cover_renderer(cover_renderer); + } + if let Some(max_log_age) = u.max_log_age { + self.set_max_log_age(max_log_age); + } + if let Some(stay_on_applied_branch) = u.stay_on_applied_branch { + self.set_stay_on_applied_branch(stay_on_applied_branch); + } + if let Some(kw_reboot_after_deploy) = u.kw_reboot_after_deploy { + self.set_kw_reboot_after_deploy(kw_reboot_after_deploy); + } + if let Some(kw_deploy_force) = u.kw_deploy_force { + self.set_kw_deploy_force(kw_deploy_force); + } + if let Some(ref target_kernel_tree) = u.target_kernel_tree { + self.set_target_kernel_tree(target_kernel_tree.clone()); + } + } + + pub fn to_snapshot(&self) -> ConfigSnapshot { + ConfigSnapshot::from_state(self) + } + + /// Recomputes derived path fields from `cache_dir` and `data_dir`. + pub fn normalize_derived_paths(&mut self) { + let cache_dir = self.cache_dir.clone(); + self.patchsets_cache_dir = format!("{cache_dir}/patchsets"); + let data_dir = self.data_dir.clone(); + self.bookmarked_patchsets_path = format!("{data_dir}/bookmarked_patchsets.json"); + self.mailing_lists_path = format!("{data_dir}/mailing_lists.json"); + self.reviewed_patchsets_path = format!("{data_dir}/reviewed_patchsets.json"); + self.logs_path = format!("{data_dir}/logs"); + } +} + +impl ConfigState { fn defaults_from_home(home: &str) -> Self { let cache_dir = format!("{home}/.cache/patch_hub"); let data_dir = format!("{home}/.local/share/patch_hub"); @@ -142,61 +199,6 @@ impl ConfigState { fn set_target_kernel_tree(&mut self, target_kernel_tree: Option) { self.target_kernel_tree = target_kernel_tree; } - - /// Merges validated field updates from the edit-config flow. - pub fn apply_update(&mut self, u: &ValidatedConfigUpdate) { - if let Some(page_size) = u.page_size { - self.set_page_size(page_size); - } - if let Some(ref cache_dir) = u.cache_dir { - self.set_cache_dir(cache_dir.clone()); - } - if let Some(ref data_dir) = u.data_dir { - self.set_data_dir(data_dir.clone()); - } - if let Some(ref git_send_email_options) = u.git_send_email_option { - self.set_git_send_email_option(git_send_email_options.clone()); - } - if let Some(ref git_am_options) = u.git_am_option { - self.set_git_am_option(git_am_options.clone()); - } - if let Some(patch_renderer) = u.patch_renderer { - self.set_patch_renderer(patch_renderer); - } - if let Some(cover_renderer) = u.cover_renderer { - self.set_cover_renderer(cover_renderer); - } - if let Some(max_log_age) = u.max_log_age { - self.set_max_log_age(max_log_age); - } - if let Some(stay_on_applied_branch) = u.stay_on_applied_branch { - self.set_stay_on_applied_branch(stay_on_applied_branch); - } - if let Some(kw_reboot_after_deploy) = u.kw_reboot_after_deploy { - self.set_kw_reboot_after_deploy(kw_reboot_after_deploy); - } - if let Some(kw_deploy_force) = u.kw_deploy_force { - self.set_kw_deploy_force(kw_deploy_force); - } - if let Some(ref target_kernel_tree) = u.target_kernel_tree { - self.set_target_kernel_tree(target_kernel_tree.clone()); - } - } - - pub fn to_snapshot(&self) -> ConfigSnapshot { - ConfigSnapshot::from_state(self) - } - - /// Recomputes derived path fields from `cache_dir` and `data_dir`. - pub fn normalize_derived_paths(&mut self) { - let cache_dir = self.cache_dir.clone(); - self.patchsets_cache_dir = format!("{cache_dir}/patchsets"); - let data_dir = self.data_dir.clone(); - self.bookmarked_patchsets_path = format!("{data_dir}/bookmarked_patchsets.json"); - self.mailing_lists_path = format!("{data_dir}/mailing_lists.json"); - self.reviewed_patchsets_path = format!("{data_dir}/reviewed_patchsets.json"); - self.logs_path = format!("{data_dir}/logs"); - } } /// Immutable view of configuration for the rest of the application (read-only). diff --git a/src/infrastructure/monitoring/logging/multi_log_file_writer.rs b/src/infrastructure/monitoring/logging/multi_log_file_writer.rs index 44caba6..cebbb26 100644 --- a/src/infrastructure/monitoring/logging/multi_log_file_writer.rs +++ b/src/infrastructure/monitoring/logging/multi_log_file_writer.rs @@ -94,7 +94,9 @@ impl MultiLogFileWriter { new_guards } +} +impl MultiLogFileWriter { fn copy_old_logs_to_new_path(old_log_file_path: String, new_log_file_path: String) { let Ok(mut old_log_file_content) = File::open(&old_log_file_path) else { event!( diff --git a/src/input/actor.rs b/src/input/actor.rs index 06953e1..e9ca846 100644 --- a/src/input/actor.rs +++ b/src/input/actor.rs @@ -128,7 +128,9 @@ impl InputActor { tracing::info!("input actor stopped"); } +} +impl InputActor { async fn deliver(&mut self, event: InputEvent) { let Some(tx) = &self.subscriber else { return; diff --git a/src/input/handle.rs b/src/input/handle.rs index fef224d..9e9527a 100644 --- a/src/input/handle.rs +++ b/src/input/handle.rs @@ -41,7 +41,9 @@ impl InputHandle { pub async fn shutdown(&self) -> Result<(), InputError> { self.send(InputMessage::Shutdown).await } +} +impl InputHandle { async fn send(&self, message: InputMessage) -> Result<(), InputError> { self.tx.send(message).await.map_err(|_| InputError::Closed) } diff --git a/src/input/mapper.rs b/src/input/mapper.rs index 9b6fbd5..b8d18ff 100644 --- a/src/input/mapper.rs +++ b/src/input/mapper.rs @@ -43,7 +43,9 @@ impl InputMapper { TerminalEvent::Resize { width, height } => Some(InputEvent::Resize { width, height }), } } +} +impl InputMapper { fn map_key_input(&mut self, key: KeyInput, context: &InputContext) -> Option { if key.kind == KeyEventKind::Release { return None; diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 512031e..8186ac0 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -140,7 +140,9 @@ impl KwActor { } tracing::info!("kw actor stopped"); } +} +impl KwActor { async fn handle_message(&mut self, message: KwMessage) -> ControlFlow<()> { let message_name = message.name(); tracing::debug!(message = message_name, "kw request received"); diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 52ec1f8..1b0e810 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -164,7 +164,9 @@ impl KwArgvService { } argv } +} +impl KwArgvService { /// Finds the reserved option a token sets, if any: an exact spelling, /// `--name=value` for a long spelling, a unique GNU getopt abbreviation /// of a `match_abbrev` long, a bundled reserved short, or an attached diff --git a/src/kw/handle.rs b/src/kw/handle.rs index fe06fff..d2a1b26 100644 --- a/src/kw/handle.rs +++ b/src/kw/handle.rs @@ -87,7 +87,9 @@ impl KwHandle { rx.await.ok(); } } +} +impl KwHandle { async fn request_result( &self, build_message: impl FnOnce(oneshot::Sender>) -> KwMessage, diff --git a/src/kw/history.rs b/src/kw/history.rs index 0f1eb90..c6f8cd7 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -81,7 +81,9 @@ impl FileKwHistoryStore { build_history_path: format!("{data_dir}/{BUILD_HISTORY_FILENAME}"), } } +} +impl FileKwHistoryStore { /// Loads a history file, or its empty default when the file does not /// exist. A corrupt file is an error rather than an empty map: history /// must never be silently clobbered by the next write. diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index c78f41a..3ad3087 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -66,7 +66,9 @@ impl LogScanService { } warnings } +} +impl LogScanService { fn is_error_line(line: &str) -> bool { line.contains("fatal error:") || line.contains(" error:") diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 1ea7c91..2245884 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -259,30 +259,6 @@ impl ReadinessService { } } - /// Newest `*Image` file directly inside `boot_dir`, if any. - #[cfg(unix)] - fn find_newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { - fs.read_dir(boot_dir) - .ok()? - .into_iter() - .filter(|entry| { - entry - .file_name() - .is_some_and(|name| name.to_string_lossy().ends_with("Image")) - && fs.is_file(entry) - }) - .map(|entry| (Self::read_image_mtime(fs, &entry), entry)) - .max_by_key(|(mtime, _)| *mtime) - .map(|(_, entry)| entry) - } - - #[cfg(unix)] - fn read_image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { - fs.metadata(path) - .and_then(|meta| meta.modified().map_err(FileSystemError::from)) - .unwrap_or(SystemTime::UNIX_EPOCH) - } - /// Reads the built kernel's release string from /// `/include/config/kernel.release`, the file a kernel build /// generates — cheaper than re-running `make kernelrelease`, and `None` @@ -334,28 +310,6 @@ impl ReadinessService { } } - fn check_kw_version(version_line: &str) -> KwVersionCheck { - match Self::parse_kw_version(version_line) { - Some(version) if version >= KW_MIN_VERSION => KwVersionCheck::Meets, - Some(_) => KwVersionCheck::Below(version_line.to_string()), - None => KwVersionCheck::Unknown, - } - } - - /// Extracts the first `X.Y[.Z]` pair from a version line: `0.10.0` and the - /// stale `beta-0.9` kw currently ships both parse. - fn parse_kw_version(line: &str) -> Option<(u32, u32)> { - let start = line.find(|c: char| c.is_ascii_digit())?; - let mut parts = line[start..].splitn(3, '.'); - let major = parts.next()?.parse().ok()?; - let minor: String = parts - .next()? - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect(); - Some((major, minor.parse().ok()?)) - } - /// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess @@ -387,33 +341,6 @@ impl ReadinessService { } } - #[cfg(unix)] - fn read_boot_once_from_file( - fs: &dyn FileSystemTrait, - path: &Path, - ) -> Result, ()> { - let content = fs.read_to_string(path).map_err(|_| ())?; - Ok(Self::parse_kw_config(&content) - .remove("boot_into_new_kernel_once") - .map(|value| match value.as_str() { - "no" => BootOnceState::Off, - "yes" => BootOnceState::On, - _ => BootOnceState::Unknown, - })) - } - - /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty - /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG - /// spec. - #[cfg(unix)] - fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), - }; - Some(Path::new(&config_home).join("kw").join(filename)) - } - /// Deploy-alone readiness gate: a deploy without a preceding build is only /// allowed when a successful build record exists for the tree and the /// lookup branch, written against the same tree path and kw env, and a @@ -551,6 +478,81 @@ impl ReadinessService { } } +impl ReadinessService { + /// Newest `*Image` file directly inside `boot_dir`, if any. + #[cfg(unix)] + fn find_newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { + fs.read_dir(boot_dir) + .ok()? + .into_iter() + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with("Image")) + && fs.is_file(entry) + }) + .map(|entry| (Self::read_image_mtime(fs, &entry), entry)) + .max_by_key(|(mtime, _)| *mtime) + .map(|(_, entry)| entry) + } + + #[cfg(unix)] + fn read_image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { + fs.metadata(path) + .and_then(|meta| meta.modified().map_err(FileSystemError::from)) + .unwrap_or(SystemTime::UNIX_EPOCH) + } + + fn check_kw_version(version_line: &str) -> KwVersionCheck { + match Self::parse_kw_version(version_line) { + Some(version) if version >= KW_MIN_VERSION => KwVersionCheck::Meets, + Some(_) => KwVersionCheck::Below(version_line.to_string()), + None => KwVersionCheck::Unknown, + } + } + + /// Extracts the first `X.Y[.Z]` pair from a version line: `0.10.0` and the + /// stale `beta-0.9` kw currently ships both parse. + fn parse_kw_version(line: &str) -> Option<(u32, u32)> { + let start = line.find(|c: char| c.is_ascii_digit())?; + let mut parts = line[start..].splitn(3, '.'); + let major = parts.next()?.parse().ok()?; + let minor: String = parts + .next()? + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + Some((major, minor.parse().ok()?)) + } + + #[cfg(unix)] + fn read_boot_once_from_file( + fs: &dyn FileSystemTrait, + path: &Path, + ) -> Result, ()> { + let content = fs.read_to_string(path).map_err(|_| ())?; + Ok(Self::parse_kw_config(&content) + .remove("boot_into_new_kernel_once") + .map(|value| match value.as_str() { + "no" => BootOnceState::Off, + "yes" => BootOnceState::On, + _ => BootOnceState::Unknown, + })) + } + + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty + /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG + /// spec. + #[cfg(unix)] + fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { + let config_home = match env.var("XDG_CONFIG_HOME") { + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.config", env.var("HOME").ok()?), + }; + Some(Path::new(&config_home).join("kw").join(filename)) + } +} + #[cfg(test)] mod tests { use std::{ diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 4465ef7..89b8c32 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -173,7 +173,10 @@ impl RemoteConfigService { } Err(RemoteRefusal::NoRemotesConfigured) } +} +#[cfg(unix)] +impl RemoteConfigService { fn read_remote_from_file( fs: &dyn FileSystemTrait, path: &Path, diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index ed17c10..0280bda 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -48,7 +48,9 @@ impl LoreApiActor { } tracing::info!("lore api actor stopped"); } +} +impl LoreApiActor { async fn handle_message(&mut self, message: LoreApiMessage) -> ControlFlow<()> { let message_name = message.name(); tracing::debug!(message = message_name, "lore api request received"); diff --git a/src/lore/application/handle.rs b/src/lore/application/handle.rs index 5b739e9..292e6b8 100644 --- a/src/lore/application/handle.rs +++ b/src/lore/application/handle.rs @@ -124,7 +124,9 @@ impl LoreApiHandle { }) .await } +} +impl LoreApiHandle { async fn request_result( &self, build_message: impl FnOnce(oneshot::Sender>) -> LoreApiMessage, diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index 9d2916f..b1f13a6 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -422,7 +422,9 @@ impl LoreService { (name, email) } +} +impl LoreService { fn extract_tag_summary(raw_patch: &str) -> PatchTagSummary { let (cover, _) = PatchsetParserService::split_cover(raw_patch); diff --git a/src/lore/domain/patch.rs b/src/lore/domain/patch.rs index 6bfdc7a..9767b86 100644 --- a/src/lore/domain/patch.rs +++ b/src/lore/domain/patch.rs @@ -46,16 +46,6 @@ impl Display for Author { } impl Patch { - fn default_version() -> usize { - 1 - } - fn default_number_in_series() -> usize { - 1 - } - fn default_total_in_series() -> usize { - 1 - } - pub fn version(&self) -> usize { self.version } @@ -79,6 +69,18 @@ impl Patch { self.set_number_in_series(&patch_tag, &patch_regex.re_patch_series); self.set_total_in_series(&patch_tag, &patch_regex.re_patch_series); } +} + +impl Patch { + fn default_version() -> usize { + 1 + } + fn default_number_in_series() -> usize { + 1 + } + fn default_total_in_series() -> usize { + 1 + } fn get_patch_tag(&self, re_patch_tag: &Regex) -> Option<&str> { match re_patch_tag.find(&self.title) { diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index 880af06..95f52e3 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -70,7 +70,9 @@ impl PatchFeedIndex { Some(page) } +} +impl PatchFeedIndex { fn ingest_patches(&mut self, feed: PatchFeed) -> Vec { let mut new_ids = Vec::new(); for mut patch in feed.patches().clone() { diff --git a/src/lore/infrastructure/patchset_fetcher.rs b/src/lore/infrastructure/patchset_fetcher.rs index 3fae1b9..52173b8 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -40,7 +40,9 @@ impl B4PatchsetFetcher { cache_dir, } } +} +impl B4PatchsetFetcher { fn extract_mbox_name_from_message_id(message_id: &str) -> String { let mut mbox_name = message_id .replace("http://lore.kernel.org/", "") diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index 6a656a9..32bd10e 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -24,7 +24,9 @@ impl MboxPatchsetParser { pub fn new(fs: Arc) -> Self { MboxPatchsetParser { fs } } +} +impl MboxPatchsetParser { fn extract_patches( fs: &dyn FileSystemTrait, mbox_path: &Path, diff --git a/src/lore/infrastructure/persistence.rs b/src/lore/infrastructure/persistence.rs index 87420c9..2600a84 100644 --- a/src/lore/infrastructure/persistence.rs +++ b/src/lore/infrastructure/persistence.rs @@ -59,7 +59,9 @@ impl FileLorePersistence { reviewed_path, } } +} +impl FileLorePersistence { fn read_json(&self, path: &str) -> Result { let reader = self.fs.open_bufreader(Path::new(path))?; from_reader(reader) diff --git a/src/render/actor.rs b/src/render/actor.rs index e41a7e1..127d5eb 100644 --- a/src/render/actor.rs +++ b/src/render/actor.rs @@ -51,7 +51,9 @@ impl RenderActor { } tracing::info!("render actor stopped"); } +} +impl RenderActor { async fn handle_message(&mut self, message: RenderMessage) -> ControlFlow<()> { let message_name = message.name(); tracing::debug!(message = message_name, "render request received"); diff --git a/src/render/cover_renderer.rs b/src/render/cover_renderer.rs index b871b26..b78be25 100644 --- a/src/render/cover_renderer.rs +++ b/src/render/cover_renderer.rs @@ -22,7 +22,9 @@ impl CoverRendererService { Ok(text) } +} +impl CoverRendererService { /// Renders a .mbx cover using the `bat` command line tool. /// /// # Errors diff --git a/src/render/handle.rs b/src/render/handle.rs index 8a96414..22b5994 100644 --- a/src/render/handle.rs +++ b/src/render/handle.rs @@ -31,7 +31,9 @@ impl RenderHandle { self.request_result(|reply| RenderMessage::RenderPatchsetPreview { request, reply }) .await } +} +impl RenderHandle { async fn request_result( &self, build_message: impl FnOnce(oneshot::Sender>) -> RenderMessage, diff --git a/src/render/patch_renderer.rs b/src/render/patch_renderer.rs index 5d89dd1..f2fc515 100644 --- a/src/render/patch_renderer.rs +++ b/src/render/patch_renderer.rs @@ -9,19 +9,6 @@ use crate::{ pub struct PatchRendererService; impl PatchRendererService { - /// Cleans patch contents before rendering for preview. Currently, it only trims - /// the trailing signature delimiter (the `--` at the end of the patch) if it - /// exists, as it is incorrectly rendered as a deletion by diff renderers. - fn clean_patch_for_preview(patch: &str) -> String { - let lines: Vec<&str> = patch.lines().collect(); - - if let Some(sig_pos) = lines.iter().position(|&line| line.trim() == "--") { - lines[..sig_pos].join("\n") - } else { - patch.to_string() - } - } - pub fn render_patch_preview( shell: &dyn ShellTrait, raw: &str, @@ -36,6 +23,21 @@ impl PatchRendererService { Ok(text) } +} + +impl PatchRendererService { + /// Cleans patch contents before rendering for preview. Currently, it only trims + /// the trailing signature delimiter (the `--` at the end of the patch) if it + /// exists, as it is incorrectly rendered as a deletion by diff renderers. + fn clean_patch_for_preview(patch: &str) -> String { + let lines: Vec<&str> = patch.lines().collect(); + + if let Some(sig_pos) = lines.iter().position(|&line| line.trim() == "--") { + lines[..sig_pos].join("\n") + } else { + patch.to_string() + } + } /// Renders a patch using the `bat` command line tool. /// diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index b524088..45cf093 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -47,7 +47,9 @@ impl TerminalActor { } tracing::info!("terminal actor stopped"); } +} +impl TerminalActor { async fn handle_message(&mut self, message: TerminalMessage) { let message_name = message.name(); tracing::debug!(message = message_name, "terminal request received"); diff --git a/src/terminal/handle.rs b/src/terminal/handle.rs index 64fe865..26ca8bd 100644 --- a/src/terminal/handle.rs +++ b/src/terminal/handle.rs @@ -63,7 +63,9 @@ impl TerminalHandle { self.request_result(|reply| TerminalMessage::Shutdown { reply }) .await } +} +impl TerminalHandle { async fn request_result( &self, build_message: impl FnOnce(oneshot::Sender>) -> TerminalMessage, diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 9752d15..f7a234b 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -51,7 +51,9 @@ impl CrosstermTerminalSession { _ => None, } } +} +impl CrosstermTerminalSession { fn wait_for_key_press_from_session( session: &mut dyn TerminalSessionApi, key: KeyCode, diff --git a/src/ui/actor.rs b/src/ui/actor.rs index fae7220..2f887a8 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -46,7 +46,9 @@ impl UiActor { } tracing::info!("ui actor stopped"); } +} +impl UiActor { fn handle_message(&self, message: UiMessage) -> ControlFlow<()> { let message_name = message.name(); tracing::debug!(message = message_name, "ui request received"); diff --git a/src/ui/core.rs b/src/ui/core.rs index 7e41311..d28a836 100644 --- a/src/ui/core.rs +++ b/src/ui/core.rs @@ -57,7 +57,9 @@ impl UiCore { popup, }) } +} +impl UiCore { fn build_navigation( &self, screen: &ScreenViewModel, diff --git a/src/ui/handle.rs b/src/ui/handle.rs index a8729f0..fd68c69 100644 --- a/src/ui/handle.rs +++ b/src/ui/handle.rs @@ -30,7 +30,9 @@ impl UiHandle { pub async fn shutdown(&self) { self.tx.send(UiMessage::Shutdown).await.ok(); } +} +impl UiHandle { async fn request_result( &self, build_message: impl FnOnce(oneshot::Sender>) -> UiMessage, diff --git a/src/ui/loading_screen.rs b/src/ui/loading_screen.rs index c15e85d..c163713 100644 --- a/src/ui/loading_screen.rs +++ b/src/ui/loading_screen.rs @@ -27,15 +27,6 @@ const LOADING_AREA_EXTRA_LINES: u16 = 2; pub(crate) struct LoadingPainter; impl LoadingPainter { - /// Gets the current spinner state and updates the tick. - fn advance_spinner() -> char { - let char_to_ret = SPINNER[unsafe { SPINNER_TICK }]; - unsafe { - SPINNER_TICK = (SPINNER_TICK + 1) % 8; - } - char_to_ret - } - /// The actual implementation of the loading screen rendering. Currently the /// loading notification is static. pub(crate) fn draw_loading_screen(f: &mut Frame, title: impl Display) { @@ -60,6 +51,17 @@ impl LoadingPainter { f.render_widget(loading_par, loading_area); } +} + +impl LoadingPainter { + /// Gets the current spinner state and updates the tick. + fn advance_spinner() -> char { + let char_to_ret = SPINNER[unsafe { SPINNER_TICK }]; + unsafe { + SPINNER_TICK = (SPINNER_TICK + 1) % 8; + } + char_to_ret + } /// # Tests /// diff --git a/src/ui/painter.rs b/src/ui/painter.rs index 6e39abc..46c0a29 100644 --- a/src/ui/painter.rs +++ b/src/ui/painter.rs @@ -57,22 +57,6 @@ impl FramePainter { } } - fn paint_title(f: &mut Frame, chunk: Rect) { - let title_block = Block::default() - .borders(Borders::ALL) - .style(Style::default()) - .title_alignment(Alignment::Center); - - let title = Paragraph::new(Text::styled( - "patch-hub", - Style::default().fg(Color::Green).bold(), - )) - .centered() - .block(title_block); - - f.render_widget(title, chunk); - } - pub(super) fn center_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { let popup_layout = Layout::default() .direction(Direction::Vertical) @@ -93,3 +77,21 @@ impl FramePainter { .split(popup_layout[1])[1] } } + +impl FramePainter { + fn paint_title(f: &mut Frame, chunk: Rect) { + let title_block = Block::default() + .borders(Borders::ALL) + .style(Style::default()) + .title_alignment(Alignment::Center); + + let title = Paragraph::new(Text::styled( + "patch-hub", + Style::default().fg(Color::Green).bold(), + )) + .centered() + .block(title_block); + + f.render_widget(title, chunk); + } +} diff --git a/src/ui/screens/details.rs b/src/ui/screens/details.rs index 2b23957..3dda29f 100644 --- a/src/ui/screens/details.rs +++ b/src/ui/screens/details.rs @@ -59,6 +59,22 @@ impl DetailsPainter { } } + pub fn build_mode_spans() -> Vec> { + vec![Span::styled( + "Patchset Details and Actions", + Style::default().fg(Color::Green), + )] + } + + pub fn build_keys_hint_span() -> Span<'static> { + Span::styled( + "(ESC / q) to return | (ENTER) run actions | (w) kw | (?) help", + Style::default().fg(Color::Red), + ) + } +} + +impl DetailsPainter { fn build_review_trailers_line(counts: &TagTrailerCounts) -> Line<'static> { let resolve_color = |n: usize| -> Style { if n == 0 { @@ -241,18 +257,4 @@ impl DetailsPainter { f.render_widget(patch_preview, chunk); } - - pub fn build_mode_spans() -> Vec> { - vec![Span::styled( - "Patchset Details and Actions", - Style::default().fg(Color::Green), - )] - } - - pub fn build_keys_hint_span() -> Span<'static> { - Span::styled( - "(ESC / q) to return | (ENTER) run actions | (w) kw | (?) help", - Style::default().fg(Color::Red), - ) - } } diff --git a/src/ui/screens/kw_ops.rs b/src/ui/screens/kw_ops.rs index 98df097..dcdd515 100644 --- a/src/ui/screens/kw_ops.rs +++ b/src/ui/screens/kw_ops.rs @@ -53,6 +53,49 @@ impl KwOpsPainter { Self::paint_log(f, scene, chunks[1]); } + /// Scroll so the newest wrapped rows sit at the bottom of the pane. + /// + /// Uses ratatui's wrap-aware [`Paragraph::line_count`] so the offset + /// matches what the painter actually renders (word wrap, tabs, wide + /// chars). Counted without a [`Block`] and with the inner width, so + /// border rows are not mixed into the text height. + pub(crate) fn compute_log_scroll_offset( + text: &str, + inner_width: u16, + inner_height: u16, + ) -> u16 { + if inner_height == 0 || inner_width == 0 { + return 0; + } + let rows = Paragraph::new(text) + .wrap(Wrap { trim: false }) + .line_count(inner_width); + rows.saturating_sub(inner_height as usize) as u16 + } + + pub fn build_mode_spans() -> Vec> { + vec![Span::styled( + "Kw operations", + Style::default().fg(Color::Green), + )] + } + + pub fn build_keys_hint_span(editing: bool) -> Span<'static> { + if editing { + Span::styled( + "(ESC) cancel edit | (ENTER) confirm", + Style::default().fg(Color::Red), + ) + } else { + Span::styled( + "(ESC / q) back | (e) edit | (b) build | (d) deploy | (D) build+deploy | (c) cancel | (r) restore | (?) help", + Style::default().fg(Color::Red), + ) + } + } +} + +impl KwOpsPainter { fn paint_form(f: &mut Frame, scene: &KwOpsScene, chunk: Rect) { let mut lines = vec![ Self::build_labeled_line("Patchset", &scene.patchset_title), @@ -131,26 +174,6 @@ impl KwOpsPainter { f.render_widget(paragraph, chunk); } - /// Scroll so the newest wrapped rows sit at the bottom of the pane. - /// - /// Uses ratatui's wrap-aware [`Paragraph::line_count`] so the offset - /// matches what the painter actually renders (word wrap, tabs, wide - /// chars). Counted without a [`Block`] and with the inner width, so - /// border rows are not mixed into the text height. - pub(crate) fn compute_log_scroll_offset( - text: &str, - inner_width: u16, - inner_height: u16, - ) -> u16 { - if inner_height == 0 || inner_width == 0 { - return 0; - } - let rows = Paragraph::new(text) - .wrap(Wrap { trim: false }) - .line_count(inner_width); - rows.saturating_sub(inner_height as usize) as u16 - } - fn build_labeled_line(label: &str, value: &str) -> Line<'static> { Self::build_colored_label(label, value, Color::White) } @@ -182,27 +205,6 @@ impl KwOpsPainter { } Line::from(spans) } - - pub fn build_mode_spans() -> Vec> { - vec![Span::styled( - "Kw operations", - Style::default().fg(Color::Green), - )] - } - - pub fn build_keys_hint_span(editing: bool) -> Span<'static> { - if editing { - Span::styled( - "(ESC) cancel edit | (ENTER) confirm", - Style::default().fg(Color::Red), - ) - } else { - Span::styled( - "(ESC / q) back | (e) edit | (b) build | (d) deploy | (D) build+deploy | (c) cancel | (r) restore | (?) help", - Style::default().fg(Color::Red), - ) - } - } } #[cfg(test)] diff --git a/src/ui/screens/popup.rs b/src/ui/screens/popup.rs index 367e4ec..f99a1db 100644 --- a/src/ui/screens/popup.rs +++ b/src/ui/screens/popup.rs @@ -87,7 +87,9 @@ impl PopupPainter { } => Self::paint_confirm(f, &scene.title, body, options, *selected, chunk), } } +} +impl PopupPainter { fn paint_info(f: &mut Frame, title: &str, body: &str, scroll: (u16, u16), chunk: Rect) { let bold_blue = Style::default() .add_modifier(Modifier::BOLD) From 15672ed18ac0147e25da725047fd784bd9020969 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sat, 3 Oct 2026 21:10:41 -0300 Subject: [PATCH 31/61] refactor(kw): group unix-only helpers into cfg(unix) submodules Readiness probes and the remote.config parser now live in cfg(unix) submodules, so one attribute covers each unix-only impl. KwMessage::name keeps a cfg(unix) impl of its own; the other single unix methods stay on their original impls. Signed-off-by: lorenzoberts --- src/kw/messages.rs | 2 +- src/kw/{readiness.rs => readiness/mod.rs} | 484 +--------------------- src/kw/readiness/unix.rs | 466 +++++++++++++++++++++ src/kw/{remote.rs => remote/mod.rs} | 198 +-------- src/kw/remote/unix.rs | 183 ++++++++ src/main.rs | 8 +- 6 files changed, 670 insertions(+), 671 deletions(-) rename src/kw/{readiness.rs => readiness/mod.rs} (69%) create mode 100644 src/kw/readiness/unix.rs rename src/kw/{remote.rs => remote/mod.rs} (65%) create mode 100644 src/kw/remote/unix.rs diff --git a/src/kw/messages.rs b/src/kw/messages.rs index 5582c3b..3fbd1dc 100644 --- a/src/kw/messages.rs +++ b/src/kw/messages.rs @@ -92,8 +92,8 @@ pub enum KwMessage { Shutdown { reply: oneshot::Sender<()> }, } +#[cfg(unix)] impl KwMessage { - #[cfg(unix)] pub fn name(&self) -> &'static str { match self { KwMessage::RecordApply { .. } => "RecordApply", diff --git a/src/kw/readiness.rs b/src/kw/readiness/mod.rs similarity index 69% rename from src/kw/readiness.rs rename to src/kw/readiness/mod.rs index 2245884..74de86c 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness/mod.rs @@ -11,276 +11,25 @@ //! injected infrastructure traits; KwActor composes them into the //! `GetReadiness` snapshot. -#[cfg(unix)] -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; - -#[cfg(unix)] -use std::collections::HashMap; -#[cfg(unix)] -use std::path::{Path, PathBuf}; -#[cfg(unix)] -use std::time::SystemTime; - -#[cfg(unix)] -use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait}; use crate::infrastructure::{ env::EnvTrait, shell::{ShellCommand, ShellTrait}, }; -#[cfg(unix)] -use crate::kw::remote; -#[cfg(unix)] -use crate::{ - config::KernelTree, - kw::history::{KwBuildRecord, KwHistoryStore}, -}; pub use crate::kw::models::readiness::{ BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwReadinessError, KwVersionCheck, TreeReadiness, }; +#[cfg(unix)] +mod unix; + pub struct ReadinessService; /// Minimum kw version this integration is verified against. pub const KW_MIN_VERSION: (u32, u32) = (0, 10); impl ReadinessService { - /// Parses kw's `key=value` config format (`.kw/build.config`, - /// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: - /// blank lines and lines starting with `#` are skipped, everything from the - /// last `#` on is stripped as a trailing comment, the key has all - /// whitespace removed, and the value is trimmed. Lines without `=` are - /// ignored, and a final line without a trailing newline still counts. - #[cfg(unix)] - pub fn parse_kw_config(content: &str) -> HashMap { - let mut entries = HashMap::new(); - for line in content.lines() { - if line.starts_with('#') || line.is_empty() { - continue; - } - let uncommented = match line.rfind('#') { - Some(index) => &line[..index], - None => line, - }; - let Some((key, value)) = uncommented.split_once('=') else { - continue; - }; - let key: String = key.chars().filter(|c| !c.is_whitespace()).collect(); - entries.insert(key, value.trim().to_string()); - } - entries - } - - /// Mirrors kw's `is_kernel_root`: the same files and directories kw checks - /// (also the set `get_maintainer.pl` relies on). `MAINTAINERS` is checked - /// with `exists` because kw uses `-e` on it and `-f` on the other files. - #[cfg(unix)] - pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { - const FILES: [&str; 5] = ["COPYING", "CREDITS", "Kbuild", "Makefile", "README"]; - const DIRS: [&str; 10] = [ - "Documentation", - "arch", - "include", - "drivers", - "fs", - "init", - "ipc", - "kernel", - "lib", - "scripts", - ]; - - FILES.iter().all(|file| fs.is_file(&path.join(file))) - && fs.exists(&path.join("MAINTAINERS")) - && DIRS.iter().all(|dir| fs.is_dir(&path.join(dir))) - } - - /// Probes whether `tree_path` is a kernel tree ready for kw operations. - /// `output_dir` is the resolved kw-env `O=` path when an env is active: kw - /// refuses to activate an env while an in-tree `.config` exists - /// (`kw_env.sh::validate_env_before_switch`), so with an env active the - /// `.config` lives only at the env's `O=` dir. - #[cfg(unix)] - pub fn probe_tree( - fs: &dyn FileSystemTrait, - tree_path: &Path, - output_dir: Option<&Path>, - ) -> TreeReadiness { - if !fs.is_dir(tree_path) { - return TreeReadiness::Missing; - } - if !Self::is_kernel_root(fs, tree_path) { - return TreeReadiness::NotAKernelRoot; - } - if !fs.is_dir(&tree_path.join(".kw")) { - return TreeReadiness::MissingKwDir; - } - if output_dir.is_some() - && (fs.is_file(&tree_path.join(".config")) - || fs.is_dir(&tree_path.join("include").join("config"))) - { - return TreeReadiness::InTreeBuildArtifacts; - } - let build_root = output_dir.unwrap_or(tree_path); - if !fs.is_file(&build_root.join(".config")) { - return TreeReadiness::MissingKernelConfig; - } - TreeReadiness::Ready { - arch: Self::read_build_arch(fs, tree_path), - } - } - - /// Reads the literal `arch=` value from `/.kw/build.config`, the same - /// value kw's image discovery globs under `arch//boot/`. Returns - /// `None` when the file or the key is absent or empty — kw's - /// `${build_config[arch]:-...}` expansion treats empty as unset — meaning - /// the caller falls back to globbing `arch/*/boot/`, a deliberate - /// divergence from kw's merged-config fallback (see - /// [`ReadinessService::find_newest_kernel_image`]). - #[cfg(unix)] - pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { - let content = fs - .read_to_string(&tree_path.join(".kw").join("build.config")) - .ok()?; - let arch = Self::parse_kw_config(&content).remove("arch")?; - if arch.is_empty() { - None - } else { - Some(arch) - } - } - - /// Resolves kw's active build output dir (`O=`) for `tree_path`, mirroring - /// kw's env handling: the env name is the content of - /// `/.kw/env.current` (trailing newlines stripped, like bash's - /// `$(< ...)`), and the output dir is - /// `{XDG_CACHE_HOME | ~/.cache}/kw/envs//`. - /// The tree path is encoded like kw 0.10's `get_encoded_pwd` — standard - /// base64 with padding, no wrapping — after trimming trailing slashes, - /// since kw encodes `$PWD` after changing into the tree. The encoded path - /// may contain `/` (standard alphabet), producing nested directories; kw - /// has the same behavior. - /// - /// kw's launcher recomputes the cache dir unconditionally, so a - /// user-exported `KW_CACHE_DIR` is intentionally ignored here too. The - /// `KWORKFLOW` rename knob is not honored: it exists for kw development. - /// - /// Returns `Ok(None)` when no env is active. The resolved dir is not - /// required to exist: kw/make create it on first build. An unreadable - /// `env.current` or an unresolvable cache base (neither `XDG_CACHE_HOME` - /// nor `HOME` set) is an error, since the env state is then unknown — - /// kw's "active but unresolvable" case. - #[cfg(unix)] - pub fn resolve_output_dir( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, - ) -> Result, KwReadinessError> { - let env_file = tree_path.join(".kw").join("env.current"); - if !fs.is_file(&env_file) { - return Ok(None); - } - let env_name = fs - .read_to_string(&env_file)? - .trim_end_matches('\n') - .to_string(); - // An empty env.current names no env; kw's $(<) read yields the same - // empty string and kw then behaves as if no env were active. - if env_name.is_empty() { - return Ok(None); - } - - let cache_base = match env.var("XDG_CACHE_HOME") { - // bash's `:-` (and the XDG spec) treat a set-but-empty value as - // unset; env::var would happily return it as Ok(""). - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.cache", env.var("HOME")?), - }; - let trimmed = tree_path.to_string_lossy(); - let normalized = match trimmed.trim_end_matches('/') { - "" => "/", - path => path, - }; - let encoded = BASE64.encode(normalized); - Ok(Some( - Path::new(&cache_base) - .join("kw") - .join("envs") - .join(encoded) - .join(env_name), - )) - } - - /// Finds the newest kernel image under `/arch/`. Candidate - /// basenames must end with `Image` (the `-name '*Image'` in kw's - /// `get_kernel_binary_name` is case-sensitive, so `Image.gz` and `image` - /// are excluded) and the most recently modified one wins, with ties broken - /// by descending path (kw's `sort -r | head -1`). - /// - /// With `arch`, only `arch//boot/` is probed — exactly kw's behavior. - /// Without `arch` this is a **deliberate divergence**, not a mirror: kw - /// falls back to the merged kw-config `arch` (packaged default `x86_64`, a - /// directory that does not exist in kernel trees, so `kw deploy` then fails - /// with exit 125), and patch-hub does not read kw's global config layers. - /// Globbing every `arch/*/boot/` gives a more useful readiness signal than - /// probing a directory that is never there — at the cost of possibly - /// reporting an image kw would not find. A green image probe with `arch=` - /// unset is therefore not a guarantee kw deploy will locate one; setting - /// `arch=` in `.kw/build.config` makes the two agree. - /// - /// Second deliberate deviation: kw's `find` recurses into boot/ - /// subdirectories, while this scans only the top level. Kernel images for - /// every arch kw supports are produced directly in boot/ (subdirs like - /// compressed/ or dts/ never hold `*Image` files), and find does not - /// descend into symlinked dirs either, so the behaviors agree on real - /// trees. - #[cfg(unix)] - pub fn find_newest_kernel_image( - fs: &dyn FileSystemTrait, - build_root: &Path, - arch: Option<&str>, - ) -> Option { - match arch { - Some(arch) => { - Self::find_newest_image_in(fs, &build_root.join("arch").join(arch).join("boot")) - } - None => fs - .read_dir(&build_root.join("arch")) - .ok()? - .into_iter() - .filter(|entry| fs.is_dir(entry)) - .filter_map(|arch_dir| { - Self::find_newest_image_in(fs, &arch_dir.join("boot")) - .map(|image| (Self::read_image_mtime(fs, &image), image)) - }) - .max_by_key(|(mtime, _)| *mtime) - .map(|(_, image)| image), - } - } - - /// Reads the built kernel's release string from - /// `/include/config/kernel.release`, the file a kernel build - /// generates — cheaper than re-running `make kernelrelease`, and `None` - /// when the build never produced one (or produced an empty one). - #[cfg(unix)] - pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option { - let release = fs - .read_to_string( - &build_root - .join("include") - .join("config") - .join("kernel.release"), - ) - .ok()?; - let release = release.trim(); - if release.is_empty() { - None - } else { - Some(release.to_string()) - } - } - /// Probes for the kw binary (`which kw`) and, when present, its version /// (`kw --version`, whose first line is the version string; repo-mode and /// installed kw both print `Branch:`/`Commit:` lines after it). @@ -309,200 +58,9 @@ impl ReadinessService { check, } } - - /// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then - /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but - /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess - /// at the home copy. A readable tree file that simply omits the key still - /// falls through, matching kw's merged-config lookup. - #[cfg(unix)] - pub fn probe_boot_once( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, - ) -> BootOnceState { - let local = tree_path.join(".kw").join("deploy.config"); - if fs.is_file(&local) { - match Self::read_boot_once_from_file(fs, &local) { - Err(()) => return BootOnceState::Unknown, - Ok(Some(state)) => return state, - Ok(None) => {} - } - } - let Some(global) = Self::resolve_xdg_kw_config_file(env, "deploy.config") else { - return BootOnceState::Unknown; - }; - if !fs.is_file(&global) { - return BootOnceState::Unknown; - } - match Self::read_boot_once_from_file(fs, &global) { - Ok(Some(state)) => state, - Ok(None) | Err(()) => BootOnceState::Unknown, - } - } - - /// Deploy-alone readiness gate: a deploy without a preceding build is only - /// allowed when a successful build record exists for the tree and the - /// lookup branch, written against the same tree path and kw env, and a - /// kernel image is still discoverable. - /// - /// `record` is the lookup keyed by the deploy target branch. `latest` is - /// the newest record for the tree across branches. When the target has - /// no keyed record but another branch does, that is - /// [`DeployAloneRefusal::HeadMismatch`], not "no build recorded". - /// - /// This is only the record-matching half of the gate — it says nothing - /// about the tree's *current* state. [`ReadinessService::evaluate_readiness`] conjoins - /// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over - /// calling this directly. - #[cfg(unix)] - pub fn check_deploy_alone( - record: Option<&KwBuildRecord>, - latest: Option<&KwBuildRecord>, - tree: &KernelTree, - head_branch: &str, - output_dir: Option<&Path>, - image: Option<&Path>, - ) -> Result<(), DeployAloneRefusal> { - let record = match record { - Some(record) => record, - None => { - if let Some(latest) = latest { - if latest.branch != head_branch { - return Err(DeployAloneRefusal::HeadMismatch { - recorded: latest.branch.clone(), - current: head_branch.to_string(), - }); - } - } - return Err(DeployAloneRefusal::NoBuildRecord); - } - }; - if !record.success { - return Err(DeployAloneRefusal::LastBuildFailed); - } - if record.branch != head_branch { - return Err(DeployAloneRefusal::HeadMismatch { - recorded: record.branch.clone(), - current: head_branch.to_string(), - }); - } - // Trailing slashes are normalized away: a config edit that only adds - // or drops one does not move the tree. - if record.tree_path.trim_end_matches('/') != tree.path().trim_end_matches('/') { - return Err(DeployAloneRefusal::TreePathDrift { - recorded: record.tree_path.clone(), - current: tree.path().to_string(), - }); - } - let current_output_dir = output_dir.map(|p| p.to_string_lossy().into_owned()); - if record.output_dir != current_output_dir { - return Err(DeployAloneRefusal::OutputDirMismatch); - } - if image.is_none() { - return Err(DeployAloneRefusal::ImageMissing); - } - Ok(()) - } - - /// Runs all readiness probes for `tree` and composes them into a - /// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — - /// resolving it (via git) is the caller's job, keeping these probes pure. - /// - /// `for_branch`, when set, is the branch deploy-alone should be judged - /// against (the branch typed on KwOps). `current_branch` still reports - /// the real HEAD so the UI can show both. - #[cfg(unix)] - #[expect(clippy::too_many_arguments)] - pub fn evaluate_readiness( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - shell: &dyn ShellTrait, - history: &dyn KwHistoryStore, - kernel_tree_id: &str, - tree: &KernelTree, - head_branch: &str, - for_branch: Option<&str>, - ) -> Result { - let tree_path = Path::new(tree.path()); - let kw_binary = Self::probe_kw_binary(env, shell); - let output_dir = Self::resolve_output_dir(fs, env, tree_path)?; - let tree_status = Self::probe_tree(fs, tree_path, output_dir.as_deref()); - let arch = match &tree_status { - TreeReadiness::Ready { arch } => arch.clone(), - _ => None, - }; - let kernel_image = Self::find_newest_kernel_image( - fs, - output_dir.as_deref().unwrap_or(tree_path), - arch.as_deref(), - ); - let lookup_branch = match for_branch - .map(str::trim) - .filter(|branch| !branch.is_empty()) - { - Some(branch) => branch, - None => head_branch, - }; - let (build_record, latest_build) = history.build_records(kernel_tree_id, lookup_branch)?; - // The tree's current state is part of the verdict: a stale image and a - // matching record must not green-light a deploy on a tree that has - // since lost its .config, .kw/, or kernel-root files. - let deploy_alone = match &tree_status { - TreeReadiness::Ready { .. } => Self::check_deploy_alone( - build_record.as_ref(), - latest_build.as_ref(), - tree, - lookup_branch, - output_dir.as_deref(), - kernel_image.as_deref(), - ), - other => Err(DeployAloneRefusal::TreeNotReady(other.clone())), - }; - Ok(KwReadiness { - kw_binary, - tree: tree_status, - output_dir, - deploy_alone, - current_branch: { - let trimmed = head_branch.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }, - deploy_remote: remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path), - boot_once: Self::probe_boot_once(fs, env, tree_path), - }) - } } impl ReadinessService { - /// Newest `*Image` file directly inside `boot_dir`, if any. - #[cfg(unix)] - fn find_newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { - fs.read_dir(boot_dir) - .ok()? - .into_iter() - .filter(|entry| { - entry - .file_name() - .is_some_and(|name| name.to_string_lossy().ends_with("Image")) - && fs.is_file(entry) - }) - .map(|entry| (Self::read_image_mtime(fs, &entry), entry)) - .max_by_key(|(mtime, _)| *mtime) - .map(|(_, entry)| entry) - } - - #[cfg(unix)] - fn read_image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { - fs.metadata(path) - .and_then(|meta| meta.modified().map_err(FileSystemError::from)) - .unwrap_or(SystemTime::UNIX_EPOCH) - } - fn check_kw_version(version_line: &str) -> KwVersionCheck { match Self::parse_kw_version(version_line) { Some(version) if version >= KW_MIN_VERSION => KwVersionCheck::Meets, @@ -524,50 +82,26 @@ impl ReadinessService { .collect(); Some((major, minor.parse().ok()?)) } - - #[cfg(unix)] - fn read_boot_once_from_file( - fs: &dyn FileSystemTrait, - path: &Path, - ) -> Result, ()> { - let content = fs.read_to_string(path).map_err(|_| ())?; - Ok(Self::parse_kw_config(&content) - .remove("boot_into_new_kernel_once") - .map(|value| match value.as_str() { - "no" => BootOnceState::Off, - "yes" => BootOnceState::On, - _ => BootOnceState::Unknown, - })) - } - - /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty - /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG - /// spec. - #[cfg(unix)] - fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), - }; - Some(Path::new(&config_home).join("kw").join(filename)) - } } #[cfg(test)] mod tests { use std::{ fs, - path::PathBuf, + path::{Path, PathBuf}, sync::atomic::{AtomicU64, Ordering}, time::{Duration, SystemTime}, }; + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + + use crate::config::KernelTree; use crate::infrastructure::{ env::MockEnvTrait, - file_system::{MockFileSystemTrait, OsFileSystem}, + file_system::{FileSystemError, MockFileSystemTrait, OsFileSystem}, shell::{MockShellTrait, ShellOutput}, }; - use crate::kw::history::FileKwHistoryStore; + use crate::kw::history::{FileKwHistoryStore, KwBuildRecord, KwHistoryStore}; use crate::kw::remote::RemoteRefusal; use super::*; diff --git a/src/kw/readiness/unix.rs b/src/kw/readiness/unix.rs new file mode 100644 index 0000000..a562e45 --- /dev/null +++ b/src/kw/readiness/unix.rs @@ -0,0 +1,466 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use crate::infrastructure::{ + env::EnvTrait, + file_system::{FileSystemError, FileSystemTrait}, + shell::ShellTrait, +}; +use crate::{ + config::KernelTree, + kw::{ + history::{KwBuildRecord, KwHistoryStore}, + remote, + }, +}; + +use super::{ + BootOnceState, DeployAloneRefusal, KwReadiness, KwReadinessError, ReadinessService, + TreeReadiness, +}; + +impl ReadinessService { + /// Parses kw's `key=value` config format (`.kw/build.config`, + /// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: + /// blank lines and lines starting with `#` are skipped, everything from the + /// last `#` on is stripped as a trailing comment, the key has all + /// whitespace removed, and the value is trimmed. Lines without `=` are + /// ignored, and a final line without a trailing newline still counts. + pub fn parse_kw_config(content: &str) -> HashMap { + let mut entries = HashMap::new(); + for line in content.lines() { + if line.starts_with('#') || line.is_empty() { + continue; + } + let uncommented = match line.rfind('#') { + Some(index) => &line[..index], + None => line, + }; + let Some((key, value)) = uncommented.split_once('=') else { + continue; + }; + let key: String = key.chars().filter(|c| !c.is_whitespace()).collect(); + entries.insert(key, value.trim().to_string()); + } + entries + } + + /// Mirrors kw's `is_kernel_root`: the same files and directories kw checks + /// (also the set `get_maintainer.pl` relies on). `MAINTAINERS` is checked + /// with `exists` because kw uses `-e` on it and `-f` on the other files. + pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { + const FILES: [&str; 5] = ["COPYING", "CREDITS", "Kbuild", "Makefile", "README"]; + const DIRS: [&str; 10] = [ + "Documentation", + "arch", + "include", + "drivers", + "fs", + "init", + "ipc", + "kernel", + "lib", + "scripts", + ]; + + FILES.iter().all(|file| fs.is_file(&path.join(file))) + && fs.exists(&path.join("MAINTAINERS")) + && DIRS.iter().all(|dir| fs.is_dir(&path.join(dir))) + } + + /// Probes whether `tree_path` is a kernel tree ready for kw operations. + /// `output_dir` is the resolved kw-env `O=` path when an env is active: kw + /// refuses to activate an env while an in-tree `.config` exists + /// (`kw_env.sh::validate_env_before_switch`), so with an env active the + /// `.config` lives only at the env's `O=` dir. + pub fn probe_tree( + fs: &dyn FileSystemTrait, + tree_path: &Path, + output_dir: Option<&Path>, + ) -> TreeReadiness { + if !fs.is_dir(tree_path) { + return TreeReadiness::Missing; + } + if !Self::is_kernel_root(fs, tree_path) { + return TreeReadiness::NotAKernelRoot; + } + if !fs.is_dir(&tree_path.join(".kw")) { + return TreeReadiness::MissingKwDir; + } + if output_dir.is_some() + && (fs.is_file(&tree_path.join(".config")) + || fs.is_dir(&tree_path.join("include").join("config"))) + { + return TreeReadiness::InTreeBuildArtifacts; + } + let build_root = output_dir.unwrap_or(tree_path); + if !fs.is_file(&build_root.join(".config")) { + return TreeReadiness::MissingKernelConfig; + } + TreeReadiness::Ready { + arch: Self::read_build_arch(fs, tree_path), + } + } + + /// Reads the literal `arch=` value from `/.kw/build.config`, the same + /// value kw's image discovery globs under `arch//boot/`. Returns + /// `None` when the file or the key is absent or empty — kw's + /// `${build_config[arch]:-...}` expansion treats empty as unset — meaning + /// the caller falls back to globbing `arch/*/boot/`, a deliberate + /// divergence from kw's merged-config fallback (see + /// [`ReadinessService::find_newest_kernel_image`]). + pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { + let content = fs + .read_to_string(&tree_path.join(".kw").join("build.config")) + .ok()?; + let arch = Self::parse_kw_config(&content).remove("arch")?; + if arch.is_empty() { + None + } else { + Some(arch) + } + } + + /// Resolves kw's active build output dir (`O=`) for `tree_path`, mirroring + /// kw's env handling: the env name is the content of + /// `/.kw/env.current` (trailing newlines stripped, like bash's + /// `$(< ...)`), and the output dir is + /// `{XDG_CACHE_HOME | ~/.cache}/kw/envs//`. + /// The tree path is encoded like kw 0.10's `get_encoded_pwd` — standard + /// base64 with padding, no wrapping — after trimming trailing slashes, + /// since kw encodes `$PWD` after changing into the tree. The encoded path + /// may contain `/` (standard alphabet), producing nested directories; kw + /// has the same behavior. + /// + /// kw's launcher recomputes the cache dir unconditionally, so a + /// user-exported `KW_CACHE_DIR` is intentionally ignored here too. The + /// `KWORKFLOW` rename knob is not honored: it exists for kw development. + /// + /// Returns `Ok(None)` when no env is active. The resolved dir is not + /// required to exist: kw/make create it on first build. An unreadable + /// `env.current` or an unresolvable cache base (neither `XDG_CACHE_HOME` + /// nor `HOME` set) is an error, since the env state is then unknown — + /// kw's "active but unresolvable" case. + pub fn resolve_output_dir( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> Result, KwReadinessError> { + let env_file = tree_path.join(".kw").join("env.current"); + if !fs.is_file(&env_file) { + return Ok(None); + } + let env_name = fs + .read_to_string(&env_file)? + .trim_end_matches('\n') + .to_string(); + // An empty env.current names no env; kw's $(<) read yields the same + // empty string and kw then behaves as if no env were active. + if env_name.is_empty() { + return Ok(None); + } + + let cache_base = match env.var("XDG_CACHE_HOME") { + // bash's `:-` (and the XDG spec) treat a set-but-empty value as + // unset; env::var would happily return it as Ok(""). + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.cache", env.var("HOME")?), + }; + let trimmed = tree_path.to_string_lossy(); + let normalized = match trimmed.trim_end_matches('/') { + "" => "/", + path => path, + }; + let encoded = BASE64.encode(normalized); + Ok(Some( + Path::new(&cache_base) + .join("kw") + .join("envs") + .join(encoded) + .join(env_name), + )) + } + + /// Finds the newest kernel image under `/arch/`. Candidate + /// basenames must end with `Image` (the `-name '*Image'` in kw's + /// `get_kernel_binary_name` is case-sensitive, so `Image.gz` and `image` + /// are excluded) and the most recently modified one wins, with ties broken + /// by descending path (kw's `sort -r | head -1`). + /// + /// With `arch`, only `arch//boot/` is probed — exactly kw's behavior. + /// Without `arch` this is a **deliberate divergence**, not a mirror: kw + /// falls back to the merged kw-config `arch` (packaged default `x86_64`, a + /// directory that does not exist in kernel trees, so `kw deploy` then fails + /// with exit 125), and patch-hub does not read kw's global config layers. + /// Globbing every `arch/*/boot/` gives a more useful readiness signal than + /// probing a directory that is never there — at the cost of possibly + /// reporting an image kw would not find. A green image probe with `arch=` + /// unset is therefore not a guarantee kw deploy will locate one; setting + /// `arch=` in `.kw/build.config` makes the two agree. + /// + /// Second deliberate deviation: kw's `find` recurses into boot/ + /// subdirectories, while this scans only the top level. Kernel images for + /// every arch kw supports are produced directly in boot/ (subdirs like + /// compressed/ or dts/ never hold `*Image` files), and find does not + /// descend into symlinked dirs either, so the behaviors agree on real + /// trees. + pub fn find_newest_kernel_image( + fs: &dyn FileSystemTrait, + build_root: &Path, + arch: Option<&str>, + ) -> Option { + match arch { + Some(arch) => { + Self::find_newest_image_in(fs, &build_root.join("arch").join(arch).join("boot")) + } + None => fs + .read_dir(&build_root.join("arch")) + .ok()? + .into_iter() + .filter(|entry| fs.is_dir(entry)) + .filter_map(|arch_dir| { + Self::find_newest_image_in(fs, &arch_dir.join("boot")) + .map(|image| (Self::read_image_mtime(fs, &image), image)) + }) + .max_by_key(|(mtime, _)| *mtime) + .map(|(_, image)| image), + } + } + + /// Reads the built kernel's release string from + /// `/include/config/kernel.release`, the file a kernel build + /// generates — cheaper than re-running `make kernelrelease`, and `None` + /// when the build never produced one (or produced an empty one). + pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option { + let release = fs + .read_to_string( + &build_root + .join("include") + .join("config") + .join("kernel.release"), + ) + .ok()?; + let release = release.trim(); + if release.is_empty() { + None + } else { + Some(release.to_string()) + } + } + + /// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but + /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess + /// at the home copy. A readable tree file that simply omits the key still + /// falls through, matching kw's merged-config lookup. + pub fn probe_boot_once( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> BootOnceState { + let local = tree_path.join(".kw").join("deploy.config"); + if fs.is_file(&local) { + match Self::read_boot_once_from_file(fs, &local) { + Err(()) => return BootOnceState::Unknown, + Ok(Some(state)) => return state, + Ok(None) => {} + } + } + let Some(global) = Self::resolve_xdg_kw_config_file(env, "deploy.config") else { + return BootOnceState::Unknown; + }; + if !fs.is_file(&global) { + return BootOnceState::Unknown; + } + match Self::read_boot_once_from_file(fs, &global) { + Ok(Some(state)) => state, + Ok(None) | Err(()) => BootOnceState::Unknown, + } + } + + /// Deploy-alone readiness gate: a deploy without a preceding build is only + /// allowed when a successful build record exists for the tree and the + /// lookup branch, written against the same tree path and kw env, and a + /// kernel image is still discoverable. + /// + /// `record` is the lookup keyed by the deploy target branch. `latest` is + /// the newest record for the tree across branches. When the target has + /// no keyed record but another branch does, that is + /// [`DeployAloneRefusal::HeadMismatch`], not "no build recorded". + /// + /// This is only the record-matching half of the gate — it says nothing + /// about the tree's *current* state. [`ReadinessService::evaluate_readiness`] conjoins + /// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over + /// calling this directly. + pub fn check_deploy_alone( + record: Option<&KwBuildRecord>, + latest: Option<&KwBuildRecord>, + tree: &KernelTree, + head_branch: &str, + output_dir: Option<&Path>, + image: Option<&Path>, + ) -> Result<(), DeployAloneRefusal> { + let record = match record { + Some(record) => record, + None => { + if let Some(latest) = latest { + if latest.branch != head_branch { + return Err(DeployAloneRefusal::HeadMismatch { + recorded: latest.branch.clone(), + current: head_branch.to_string(), + }); + } + } + return Err(DeployAloneRefusal::NoBuildRecord); + } + }; + if !record.success { + return Err(DeployAloneRefusal::LastBuildFailed); + } + if record.branch != head_branch { + return Err(DeployAloneRefusal::HeadMismatch { + recorded: record.branch.clone(), + current: head_branch.to_string(), + }); + } + // Trailing slashes are normalized away: a config edit that only adds + // or drops one does not move the tree. + if record.tree_path.trim_end_matches('/') != tree.path().trim_end_matches('/') { + return Err(DeployAloneRefusal::TreePathDrift { + recorded: record.tree_path.clone(), + current: tree.path().to_string(), + }); + } + let current_output_dir = output_dir.map(|p| p.to_string_lossy().into_owned()); + if record.output_dir != current_output_dir { + return Err(DeployAloneRefusal::OutputDirMismatch); + } + if image.is_none() { + return Err(DeployAloneRefusal::ImageMissing); + } + Ok(()) + } + + /// Runs all readiness probes for `tree` and composes them into a + /// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — + /// resolving it (via git) is the caller's job, keeping these probes pure. + /// + /// `for_branch`, when set, is the branch deploy-alone should be judged + /// against (the branch typed on KwOps). `current_branch` still reports + /// the real HEAD so the UI can show both. + #[expect(clippy::too_many_arguments)] + pub fn evaluate_readiness( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + shell: &dyn ShellTrait, + history: &dyn KwHistoryStore, + kernel_tree_id: &str, + tree: &KernelTree, + head_branch: &str, + for_branch: Option<&str>, + ) -> Result { + let tree_path = Path::new(tree.path()); + let kw_binary = Self::probe_kw_binary(env, shell); + let output_dir = Self::resolve_output_dir(fs, env, tree_path)?; + let tree_status = Self::probe_tree(fs, tree_path, output_dir.as_deref()); + let arch = match &tree_status { + TreeReadiness::Ready { arch } => arch.clone(), + _ => None, + }; + let kernel_image = Self::find_newest_kernel_image( + fs, + output_dir.as_deref().unwrap_or(tree_path), + arch.as_deref(), + ); + let lookup_branch = match for_branch + .map(str::trim) + .filter(|branch| !branch.is_empty()) + { + Some(branch) => branch, + None => head_branch, + }; + let (build_record, latest_build) = history.build_records(kernel_tree_id, lookup_branch)?; + // The tree's current state is part of the verdict: a stale image and a + // matching record must not green-light a deploy on a tree that has + // since lost its .config, .kw/, or kernel-root files. + let deploy_alone = match &tree_status { + TreeReadiness::Ready { .. } => Self::check_deploy_alone( + build_record.as_ref(), + latest_build.as_ref(), + tree, + lookup_branch, + output_dir.as_deref(), + kernel_image.as_deref(), + ), + other => Err(DeployAloneRefusal::TreeNotReady(other.clone())), + }; + Ok(KwReadiness { + kw_binary, + tree: tree_status, + output_dir, + deploy_alone, + current_branch: { + let trimmed = head_branch.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }, + deploy_remote: remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path), + boot_once: Self::probe_boot_once(fs, env, tree_path), + }) + } +} + +impl ReadinessService { + /// Newest `*Image` file directly inside `boot_dir`, if any. + fn find_newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { + fs.read_dir(boot_dir) + .ok()? + .into_iter() + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with("Image")) + && fs.is_file(entry) + }) + .map(|entry| (Self::read_image_mtime(fs, &entry), entry)) + .max_by_key(|(mtime, _)| *mtime) + .map(|(_, entry)| entry) + } + + fn read_image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { + fs.metadata(path) + .and_then(|meta| meta.modified().map_err(FileSystemError::from)) + .unwrap_or(SystemTime::UNIX_EPOCH) + } + + fn read_boot_once_from_file( + fs: &dyn FileSystemTrait, + path: &Path, + ) -> Result, ()> { + let content = fs.read_to_string(path).map_err(|_| ())?; + Ok(Self::parse_kw_config(&content) + .remove("boot_into_new_kernel_once") + .map(|value| match value.as_str() { + "no" => BootOnceState::Off, + "yes" => BootOnceState::On, + _ => BootOnceState::Unknown, + })) + } + + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty + /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG + /// spec. + fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { + let config_home = match env.var("XDG_CONFIG_HOME") { + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.config", env.var("HOME").ok()?), + }; + Some(Path::new(&config_home).join("kw").join(filename)) + } +} diff --git a/src/kw/remote.rs b/src/kw/remote/mod.rs similarity index 65% rename from src/kw/remote.rs rename to src/kw/remote/mod.rs index 89b8c32..ae244ff 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote/mod.rs @@ -13,14 +13,13 @@ //! tree is configured for. #[cfg(unix)] -use std::path::{Path, PathBuf}; +pub use crate::kw::models::remote::ParsedRemoteConfig; +pub use crate::kw::models::remote::{KwRemote, RemoteRefusal}; #[cfg(unix)] -use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; - +mod unix; #[cfg(unix)] -pub use crate::kw::models::remote::ParsedRemoteConfig; -pub use crate::kw::models::remote::{KwRemote, RemoteRefusal}; +pub use unix::RemoteConfigService; impl KwRemote { /// The `--remote` token kw's deploy parser accepts: @@ -33,192 +32,13 @@ impl KwRemote { } } -#[cfg(unix)] -struct HostBuilder { - name: String, - hostname: Option, - port: Option, - user: Option, -} - -#[cfg(unix)] -impl HostBuilder { - fn new(name: &str) -> Self { - Self { - name: name.to_string(), - hostname: None, - port: None, - user: None, - } - } - - fn finish(self) -> Option { - let hostname = self.hostname.filter(|value| !value.is_empty())?; - let port = match self.port.as_deref() { - None | Some("") => 22, - Some(value) => value.parse().ok().filter(|port| *port != 0)?, - }; - let user = self.user.filter(|value| !value.is_empty()); - Some(KwRemote { - name: self.name, - hostname, - port, - user, - }) - } -} - -#[cfg(unix)] -pub struct RemoteConfigService; - -#[cfg(unix)] -impl RemoteConfigService { - /// Parses kw's ssh-config-like `remote.config`. Blank lines and comments - /// are skipped; `#kw-default=` (anywhere) names the default Host, - /// last occurrence winning; `Host ` starts a stanza; `Hostname`, - /// `Port`, and `User` are read case-insensitively; `IdentityFile` and - /// other keys are tolerated and ignored. Port defaults to 22 when unset. - /// Duplicate Host names keep the last complete stanza. - pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { - let mut parsed = ParsedRemoteConfig::default(); - let mut current: Option = None; - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if let Some(name) = trimmed.strip_prefix("#kw-default=") { - let name = name.trim(); - if !name.is_empty() { - parsed.default = Some(name.to_string()); - } - continue; - } - if trimmed.starts_with('#') { - continue; - } - - let Some((keyword, rest)) = Self::split_keyword(trimmed) else { - continue; - }; - if keyword.eq_ignore_ascii_case("host") { - if let Some(host) = current.take().and_then(HostBuilder::finish) { - Self::upsert_host(&mut parsed.hosts, host); - } - let name = rest.split_whitespace().next().unwrap_or(""); - if !name.is_empty() { - current = Some(HostBuilder::new(name)); - } - continue; - } - let Some(builder) = current.as_mut() else { - continue; - }; - if keyword.eq_ignore_ascii_case("hostname") { - if !rest.is_empty() { - builder.hostname = Some(rest.to_string()); - } - } else if keyword.eq_ignore_ascii_case("port") { - if !rest.is_empty() { - builder.port = Some(rest.to_string()); - } - } else if keyword.eq_ignore_ascii_case("user") && !rest.is_empty() { - builder.user = Some(rest.to_string()); - } - } - if let Some(host) = current.and_then(HostBuilder::finish) { - Self::upsert_host(&mut parsed.hosts, host); - } - parsed - } - - /// Picks the deploy remote from a parsed file: the `#kw-default=` Host - /// when set, otherwise the only Host. Multiple hosts with no default, or - /// a default that names no complete Host, are refusals. - pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result { - if let Some(name) = parsed.default.as_deref() { - return parsed - .hosts - .iter() - .find(|host| host.name == name) - .cloned() - .ok_or_else(|| RemoteRefusal::DefaultNotFound { - name: name.to_string(), - }); - } - match parsed.hosts.as_slice() { - [] => Err(RemoteRefusal::NoRemotesConfigured), - [only] => Ok(only.clone()), - hosts => Err(RemoteRefusal::NoDefault { count: hosts.len() }), - } - } - - /// Resolves the remote kw deploy will be pointed at. See the module docs - /// for the file lookup order. - pub fn resolve_deploy_remote( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, - ) -> Result { - let local = tree_path.join(".kw").join("remote.config"); - if fs.is_file(&local) { - return Self::read_remote_from_file(fs, &local); - } - let Some(global) = Self::resolve_xdg_remote_config(env) else { - return Err(RemoteRefusal::NoRemotesConfigured); - }; - if fs.is_file(&global) { - return Self::read_remote_from_file(fs, &global); - } - Err(RemoteRefusal::NoRemotesConfigured) - } -} - -#[cfg(unix)] -impl RemoteConfigService { - fn read_remote_from_file( - fs: &dyn FileSystemTrait, - path: &Path, - ) -> Result { - let content = fs - .read_to_string(path) - .map_err(|_| RemoteRefusal::NoRemotesConfigured)?; - Self::select_deploy_remote(&Self::parse_remote_config(&content)) - } - - /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/remote.config`. A set-but-empty - /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG - /// spec — otherwise the path would be relative to cwd. - fn resolve_xdg_remote_config(env: &dyn EnvTrait) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), - }; - Some(Path::new(&config_home).join("kw").join("remote.config")) - } - - fn split_keyword(line: &str) -> Option<(&str, &str)> { - let keyword_end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); - let keyword = &line[..keyword_end]; - if keyword.is_empty() { - return None; - } - Some((keyword, line[keyword_end..].trim())) - } - - fn upsert_host(hosts: &mut Vec, host: KwRemote) { - if let Some(existing) = hosts.iter_mut().find(|entry| entry.name == host.name) { - *existing = host; - } else { - hosts.push(host); - } - } -} - #[cfg(test)] mod tests { - use std::{collections::HashMap, path::PathBuf, sync::Arc}; + use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, + }; use crate::infrastructure::{ env::{EnvError, MockEnvTrait}, diff --git a/src/kw/remote/unix.rs b/src/kw/remote/unix.rs new file mode 100644 index 0000000..0c30744 --- /dev/null +++ b/src/kw/remote/unix.rs @@ -0,0 +1,183 @@ +use std::path::{Path, PathBuf}; + +use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; + +use super::{KwRemote, ParsedRemoteConfig, RemoteRefusal}; + +struct HostBuilder { + name: String, + hostname: Option, + port: Option, + user: Option, +} + +impl HostBuilder { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + hostname: None, + port: None, + user: None, + } + } + + fn finish(self) -> Option { + let hostname = self.hostname.filter(|value| !value.is_empty())?; + let port = match self.port.as_deref() { + None | Some("") => 22, + Some(value) => value.parse().ok().filter(|port| *port != 0)?, + }; + let user = self.user.filter(|value| !value.is_empty()); + Some(KwRemote { + name: self.name, + hostname, + port, + user, + }) + } +} + +pub struct RemoteConfigService; + +impl RemoteConfigService { + /// Parses kw's ssh-config-like `remote.config`. Blank lines and comments + /// are skipped; `#kw-default=` (anywhere) names the default Host, + /// last occurrence winning; `Host ` starts a stanza; `Hostname`, + /// `Port`, and `User` are read case-insensitively; `IdentityFile` and + /// other keys are tolerated and ignored. Port defaults to 22 when unset. + /// Duplicate Host names keep the last complete stanza. + pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { + let mut parsed = ParsedRemoteConfig::default(); + let mut current: Option = None; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(name) = trimmed.strip_prefix("#kw-default=") { + let name = name.trim(); + if !name.is_empty() { + parsed.default = Some(name.to_string()); + } + continue; + } + if trimmed.starts_with('#') { + continue; + } + + let Some((keyword, rest)) = Self::split_keyword(trimmed) else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + if let Some(host) = current.take().and_then(HostBuilder::finish) { + Self::upsert_host(&mut parsed.hosts, host); + } + let name = rest.split_whitespace().next().unwrap_or(""); + if !name.is_empty() { + current = Some(HostBuilder::new(name)); + } + continue; + } + let Some(builder) = current.as_mut() else { + continue; + }; + if keyword.eq_ignore_ascii_case("hostname") { + if !rest.is_empty() { + builder.hostname = Some(rest.to_string()); + } + } else if keyword.eq_ignore_ascii_case("port") { + if !rest.is_empty() { + builder.port = Some(rest.to_string()); + } + } else if keyword.eq_ignore_ascii_case("user") && !rest.is_empty() { + builder.user = Some(rest.to_string()); + } + } + if let Some(host) = current.and_then(HostBuilder::finish) { + Self::upsert_host(&mut parsed.hosts, host); + } + parsed + } + + /// Picks the deploy remote from a parsed file: the `#kw-default=` Host + /// when set, otherwise the only Host. Multiple hosts with no default, or + /// a default that names no complete Host, are refusals. + pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result { + if let Some(name) = parsed.default.as_deref() { + return parsed + .hosts + .iter() + .find(|host| host.name == name) + .cloned() + .ok_or_else(|| RemoteRefusal::DefaultNotFound { + name: name.to_string(), + }); + } + match parsed.hosts.as_slice() { + [] => Err(RemoteRefusal::NoRemotesConfigured), + [only] => Ok(only.clone()), + hosts => Err(RemoteRefusal::NoDefault { count: hosts.len() }), + } + } + + /// Resolves the remote kw deploy will be pointed at. See the [`remote`](super) + /// module docs for the file lookup order. + pub fn resolve_deploy_remote( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> Result { + let local = tree_path.join(".kw").join("remote.config"); + if fs.is_file(&local) { + return Self::read_remote_from_file(fs, &local); + } + let Some(global) = Self::resolve_xdg_remote_config(env) else { + return Err(RemoteRefusal::NoRemotesConfigured); + }; + if fs.is_file(&global) { + return Self::read_remote_from_file(fs, &global); + } + Err(RemoteRefusal::NoRemotesConfigured) + } +} + +impl RemoteConfigService { + fn read_remote_from_file( + fs: &dyn FileSystemTrait, + path: &Path, + ) -> Result { + let content = fs + .read_to_string(path) + .map_err(|_| RemoteRefusal::NoRemotesConfigured)?; + Self::select_deploy_remote(&Self::parse_remote_config(&content)) + } + + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/remote.config`. A set-but-empty + /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG + /// spec — otherwise the path would be relative to cwd. + fn resolve_xdg_remote_config(env: &dyn EnvTrait) -> Option { + let config_home = match env.var("XDG_CONFIG_HOME") { + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.config", env.var("HOME").ok()?), + }; + Some(Path::new(&config_home).join("kw").join("remote.config")) + } + + fn split_keyword(line: &str) -> Option<(&str, &str)> { + let keyword_end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); + let keyword = &line[..keyword_end]; + if keyword.is_empty() { + return None; + } + Some((keyword, line[keyword_end..].trim())) + } + + fn upsert_host(hosts: &mut Vec, host: KwRemote) { + if let Some(existing) = hosts.iter_mut().find(|entry| entry.name == host.name) { + *existing = host; + } else { + hosts.push(host); + } + } +} diff --git a/src/main.rs b/src/main.rs index 2f8fa06..13063ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,8 +16,6 @@ use clap::Parser; use cli::Cli; use color_eyre::{eyre::eyre, Result}; use config::{ConfigActor, ConfigService}; -#[cfg(unix)] -use infrastructure::process::OsProcess; use infrastructure::{ env::OsEnv, file_system::{FileSystemTrait, OsFileSystem}, @@ -27,8 +25,6 @@ use infrastructure::{ terminal::init, }; use input::{actor::InputActor, event::InputEvent}; -#[cfg(unix)] -use kw::actor::KwActor; use kw::history::{FileKwHistoryStore, KwHistoryStore}; use lore::{ application::{actor::LoreApiActor, cache::CacheTtl, service::LoreService}, @@ -40,13 +36,13 @@ use lore::{ }, }; use render::{actor::RenderActor, ShellRenderService}; -#[cfg(unix)] -use std::path::Path; use std::{ops::ControlFlow, sync::Arc}; use terminal::{actor::TerminalActor, session::CrosstermTerminalSession}; use tokio::sync::mpsc; use tracing::{event, Level}; use ui::actor::UiActor; +#[cfg(unix)] +use {infrastructure::process::OsProcess, kw::actor::KwActor, std::path::Path}; #[tokio::main] async fn main() -> Result<()> { From b2039022bee08babe6cee1d9f992efb6e96b5952 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 08:38:07 -0300 Subject: [PATCH 32/61] refactor: import model types from their models modules Signed-off-by: lorenzoberts --- src/app/actor.rs | 33 +++++----- src/app/dependencies.rs | 2 +- src/app/flows/bookmarked.rs | 2 +- src/app/flows/details_actions.rs | 2 +- src/app/flows/edit_config.rs | 2 +- src/app/flows/kw_ops.rs | 14 ++--- src/app/flows/latest.rs | 2 +- src/app/flows/mail_list.rs | 2 +- src/app/flows/open_patchset.rs | 2 +- src/app/input.rs | 5 +- src/app/integration_tests/flow_errors.rs | 2 +- .../integration_tests/helpers/app_harness.rs | 2 +- src/app/integration_tests/kw_ops.rs | 14 +++-- src/app/integration_tests/kw_status.rs | 2 +- src/app/integration_tests/patchset_actions.rs | 4 +- src/app/mod.rs | 22 ++++--- src/app/models/kw_ops.rs | 2 +- src/app/popup.rs | 2 +- src/app/screens/kw_ops.rs | 9 +-- src/app/screens/latest.rs | 4 +- src/app/screens/mail_list.rs | 2 +- src/app/state.rs | 4 +- src/app/view_model.rs | 61 ++++++++++--------- src/kw/actor/actor_test.rs | 9 ++- src/kw/actor/deploy_test.rs | 3 +- src/kw/actor/mod.rs | 11 ++-- src/kw/errors.rs | 2 +- src/kw/handle.rs | 3 +- src/kw/history.rs | 4 +- src/kw/messages.rs | 3 +- src/kw/mod.rs | 2 +- src/kw/readiness/mod.rs | 13 ++-- src/kw/readiness/unix.rs | 13 ++-- src/kw/remote/mod.rs | 5 +- src/kw/remote/unix.rs | 2 +- src/lore/application/actor.rs | 2 +- src/lore/application/cache.rs | 5 +- src/lore/application/handle.rs | 2 +- src/lore/application/messages.rs | 2 +- src/lore/application/mod.rs | 2 +- src/lore/application/service.rs | 8 +-- src/main.rs | 2 +- src/ui/actor.rs | 6 +- src/ui/core.rs | 4 +- src/ui/handle.rs | 2 +- src/ui/messages.rs | 2 +- src/ui/scene.rs | 4 +- src/ui/screens/bookmarked.rs | 2 +- src/ui/screens/details.rs | 4 +- src/ui/screens/edit_config.rs | 4 +- src/ui/screens/kw_ops.rs | 2 +- src/ui/screens/latest.rs | 2 +- src/ui/screens/mailing_list.rs | 2 +- src/ui/screens/popup.rs | 2 +- 54 files changed, 169 insertions(+), 156 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 5316bf6..aeb168e 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -18,7 +18,7 @@ use crate::{ app::{ handle::AppHandle, loading::TerminalLoadingIndicator, - popup::{AppPopup, ConfirmAction}, + models::popup::{AppPopup, ConfirmAction}, screens::CurrentScreen, App, }, @@ -378,7 +378,8 @@ mod tests { kw::history::MockKwHistoryStore, lore::{ application::{ - actor::LoreApiActor, cache::CacheTtl, handle::LoreApiHandle, service::LoreService, + actor::LoreApiActor, handle::LoreApiHandle, models::cache::CacheTtl, + service::LoreService, }, domain::mailing_list::MailingList, infrastructure::{ @@ -564,8 +565,8 @@ mod tests { render.shutdown().await; } - fn sample_kw_ops() -> crate::app::screens::kw_ops::KwOpsState { - crate::app::screens::kw_ops::KwOpsState::new( + fn sample_kw_ops() -> crate::app::models::kw_ops::KwOpsState { + crate::app::models::kw_ops::KwOpsState::new( "title".to_string(), "mid".to_string(), "linux".to_string(), @@ -574,20 +575,20 @@ mod tests { "branch": "main" })) .unwrap(), - crate::kw::readiness::KwReadiness { - kw_binary: crate::kw::readiness::KwBinaryProbe { + crate::kw::models::readiness::KwReadiness { + kw_binary: crate::kw::models::readiness::KwBinaryProbe { available: true, version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::readiness::KwVersionCheck::Meets, + check: crate::kw::models::readiness::KwVersionCheck::Meets, }, - tree: crate::kw::readiness::TreeReadiness::Ready { + tree: crate::kw::models::readiness::TreeReadiness::Ready { arch: Some("x86_64".to_string()), }, output_dir: None, - deploy_alone: Err(crate::kw::readiness::DeployAloneRefusal::NoBuildRecord), + deploy_alone: Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord), current_branch: Some("main".to_string()), - deploy_remote: Err(crate::kw::remote::RemoteRefusal::NoRemotesConfigured), - boot_once: crate::kw::readiness::BootOnceState::Unknown, + deploy_remote: Err(crate::kw::models::remote::RemoteRefusal::NoRemotesConfigured), + boot_once: crate::kw::models::readiness::BootOnceState::Unknown, }, ) } @@ -596,7 +597,7 @@ mod tests { async fn proceed_with_boot_once_acks_and_clears_pending() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::screens::kw_ops::DeployStartKind::Deploy); + ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::Deploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); @@ -621,7 +622,7 @@ mod tests { async fn back_out_clears_pending_without_acknowledging() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::screens::kw_ops::DeployStartKind::BuildThenDeploy); + ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::BuildThenDeploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); @@ -639,7 +640,7 @@ mod tests { fn closing_the_boot_once_popup_clears_pending() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::screens::kw_ops::DeployStartKind::Deploy); + ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::Deploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); @@ -653,14 +654,14 @@ mod tests { fn closing_the_quit_popup_does_not_touch_pending_deploy() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::screens::kw_ops::DeployStartKind::Deploy); + ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::Deploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::quit_while_job_running()); AppActor::dismiss_open_popup(&mut app); assert!(app.state.popup.is_none()); assert_eq!( - Some(crate::app::screens::kw_ops::DeployStartKind::Deploy), + Some(crate::app::models::kw_ops::DeployStartKind::Deploy), app.state.kw.ops.as_ref().unwrap().pending_deploy ); } diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 65afca9..879fc6f 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -4,7 +4,7 @@ use crate::{ app::errors::AppError, config::ConfigSnapshot, infrastructure::{env::EnvTrait, shell::ShellTrait}, - kw::readiness::{KwVersionCheck, ReadinessService}, + kw::{models::readiness::KwVersionCheck, readiness::ReadinessService}, render_prefs::PatchRenderer, }; diff --git a/src/app/flows/bookmarked.rs b/src/app/flows/bookmarked.rs index 09a5b72..b01a72c 100644 --- a/src/app/flows/bookmarked.rs +++ b/src/app/flows/bookmarked.rs @@ -3,7 +3,7 @@ use tracing::debug; use color_eyre::Result; use crate::{ - app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App}, + app::{loading::LoadingIndicator, models::popup::AppPopup, screens::CurrentScreen, App}, input::event::InputEvent, }; diff --git a/src/app/flows/details_actions.rs b/src/app/flows/details_actions.rs index 4c514dd..7090f89 100644 --- a/src/app/flows/details_actions.rs +++ b/src/app/flows/details_actions.rs @@ -5,7 +5,7 @@ use ratatui::crossterm::event::KeyCode; use tracing::debug; use crate::{ - app::{popup::AppPopup, screens::CurrentScreen, App}, + app::{models::popup::AppPopup, screens::CurrentScreen, App}, input::event::{InputEvent, ScrollAmount}, terminal::handle::TerminalHandle, }; diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 1c273db..4473aa9 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -2,7 +2,7 @@ use color_eyre::Result; use tracing::debug; use crate::{ - app::{popup::AppPopup, screens::CurrentScreen, App}, + app::{models::popup::AppPopup, screens::CurrentScreen, App}, input::event::InputEvent, }; diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 09e1947..579433a 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -4,11 +4,11 @@ use color_eyre::Result; use crate::{ app::{ - popup::AppPopup, - screens::{ + models::{ kw_ops::{DeployStartKind, KwOpsFocus, KwOpsState}, - CurrentScreen, + popup::AppPopup, }, + screens::CurrentScreen, App, }, infrastructure::file_system::FileSystemError, @@ -16,7 +16,7 @@ use crate::{ kw::{ errors::{KwError, KwStartError}, messages::{DeployOptions, StartRequest}, - readiness::BootOnceState, + models::readiness::BootOnceState, status::{KwJobStatus, KwStatusSnapshot}, }, }; @@ -566,12 +566,12 @@ impl App { #[cfg(test)] mod tests { use super::*; - use crate::app::screens::kw_ops::KwOpsState; - use crate::kw::readiness::{ + use crate::app::models::kw_ops::KwOpsState; + use crate::kw::models::readiness::{ BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, TreeReadiness, }; - use crate::kw::remote::RemoteRefusal; + use crate::kw::models::remote::RemoteRefusal; fn sample_ops() -> KwOpsState { KwOpsState::new( diff --git a/src/app/flows/latest.rs b/src/app/flows/latest.rs index afe62d8..bd382dd 100644 --- a/src/app/flows/latest.rs +++ b/src/app/flows/latest.rs @@ -3,7 +3,7 @@ use tracing::debug; use color_eyre::Result; use crate::{ - app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App}, + app::{loading::LoadingIndicator, models::popup::AppPopup, screens::CurrentScreen, App}, input::event::InputEvent, }; diff --git a/src/app/flows/mail_list.rs b/src/app/flows/mail_list.rs index cbe8ab4..5e7af5e 100644 --- a/src/app/flows/mail_list.rs +++ b/src/app/flows/mail_list.rs @@ -4,7 +4,7 @@ use color_eyre::Result; use tracing::debug; use crate::{ - app::{loading::LoadingIndicator, popup::AppPopup, screens::CurrentScreen, App}, + app::{loading::LoadingIndicator, models::popup::AppPopup, screens::CurrentScreen, App}, input::event::InputEvent, }; diff --git a/src/app/flows/open_patchset.rs b/src/app/flows/open_patchset.rs index a5aa856..6edd6a9 100644 --- a/src/app/flows/open_patchset.rs +++ b/src/app/flows/open_patchset.rs @@ -1,6 +1,6 @@ use color_eyre::Result; -use crate::app::{popup::AppPopup, screens::CurrentScreen, App, B4Result}; +use crate::app::{models::popup::AppPopup, screens::CurrentScreen, App, B4Result}; #[derive(Debug, PartialEq)] enum OpenPatchsetAction { diff --git a/src/app/input.rs b/src/app/input.rs index 3bd425d..9951654 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1,4 +1,7 @@ -use crate::{app::popup::AppPopup, app::App, input::context::InputContext}; +use crate::{ + app::{models::popup::AppPopup, App}, + input::context::InputContext, +}; impl App { /// Projects App state into the context needed by the input mapper. diff --git a/src/app/integration_tests/flow_errors.rs b/src/app/integration_tests/flow_errors.rs index bef3328..73ddaee 100644 --- a/src/app/integration_tests/flow_errors.rs +++ b/src/app/integration_tests/flow_errors.rs @@ -1,7 +1,7 @@ use std::ops::ControlFlow; use crate::{ - app::{popup::AppPopup, screens::CurrentScreen}, + app::{models::popup::AppPopup, screens::CurrentScreen}, input::event::InputEvent, }; diff --git a/src/app/integration_tests/helpers/app_harness.rs b/src/app/integration_tests/helpers/app_harness.rs index dcf38dd..df632bb 100644 --- a/src/app/integration_tests/helpers/app_harness.rs +++ b/src/app/integration_tests/helpers/app_harness.rs @@ -7,7 +7,7 @@ use crate::{ config::{ConfigHandle, ConfigState}, infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, kw::history::MockKwHistoryStore, - lore::application::{cache::BootstrapLoreData, handle::LoreApiHandle}, + lore::application::{handle::LoreApiHandle, models::cache::BootstrapLoreData}, render::handle::RenderHandle, terminal::{handle::TerminalHandle, messages::TerminalMessage}, }; diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index ceee35f..2fcbd22 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -1,6 +1,6 @@ use crate::{ app::{ - popup::AppPopup, + models::popup::AppPopup, screens::{details_actions::PatchsetDetailsState, CurrentScreen}, }, input::event::InputEvent, @@ -79,11 +79,14 @@ mod unix { input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, kw::{ actor::KwActor, - history::{KwBuildRecord, MockKwHistoryStore}, - readiness::{BootOnceState, DeployAloneRefusal}, + history::MockKwHistoryStore, + models::{ + history::KwBuildRecord, + readiness::{BootOnceState, DeployAloneRefusal}, + }, status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, }, - lore::application::cache::BootstrapLoreData, + lore::application::models::cache::BootstrapLoreData, terminal::{ actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, }, @@ -99,8 +102,7 @@ mod unix { app_harness::{dummy_config_handle, dummy_render_handle, dummy_terminal_handle}, lore::{lore_handle_with_persistence, sample_mailing_list}, }, - popup::AppPopup, - screens::kw_ops::DeployStartKind, + models::{kw_ops::DeployStartKind, popup::AppPopup}, }; static LOG_DIR_SEQ: AtomicU64 = AtomicU64::new(0); diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index cd974bf..f964416 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -18,7 +18,7 @@ use crate::{ }, input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, kw::{actor::KwActor, history::MockKwHistoryStore, messages::StartRequest}, - lore::application::cache::BootstrapLoreData, + lore::application::models::cache::BootstrapLoreData, terminal::{actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi}, ui::{actor::UiActor, scene::UiScene}, }; diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index 39d97f3..553e1f5 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -8,7 +8,7 @@ use tokio::{spawn, sync::mpsc}; use crate::{ app::{ - popup::AppPopup, + models::popup::AppPopup, screens::{ details_actions::{PatchsetAction, PatchsetDetailsState}, CurrentScreen, @@ -26,7 +26,7 @@ use crate::{ actor::KwActor, history::MockKwHistoryStore, messages::StartRequest, status::KwJobStatus, }, lore::application::{ - cache::BootstrapLoreData, handle::LoreApiHandle, messages::LoreApiMessage, + handle::LoreApiHandle, messages::LoreApiMessage, models::cache::BootstrapLoreData, }, }; diff --git a/src/app/mod.rs b/src/app/mod.rs index e7e4674..7daaba6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,7 +17,7 @@ pub(crate) mod flows; pub mod handle; pub mod input; pub(crate) mod loading; -pub mod models; +pub(crate) mod models; pub mod popup; pub mod screens; pub mod state; @@ -49,20 +49,20 @@ use crate::{ shell::ShellTrait, }, kw::{ - handle::KwHandle, - history::{KwApplyRecord, KwHistoryStore}, + handle::KwHandle, history::KwHistoryStore, models::history::KwApplyRecord, status::KwJobStatus, }, lore::{ application::{ - cache::{BootstrapLoreData, CacheMode}, errors::LoreError, handle::LoreApiHandle, + models::cache::{BootstrapLoreData, CacheMode}, }, domain::patch::Patch, }, render::{handle::RenderHandle, RenderPatchsetRequest}, }; +use models::view_model::AppViewModel; use screens::{ bookmarked::BookmarkedPatchsetsState, details_actions::{PatchsetAction, PatchsetDetailsState}, @@ -72,7 +72,6 @@ use screens::{ CurrentScreen, }; pub use state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}; -pub use view_model::AppViewModel; /// Injected capabilities used by `App` orchestration (not screen state). pub struct AppServices { @@ -493,11 +492,11 @@ impl App { /// The popup blocking an apply while a kw job runs, if a job is in /// fact running. An unreachable actor cannot be running a job, so a /// status-query failure lets the apply proceed. - async fn kw_job_running_popup(&self) -> Option { + async fn kw_job_running_popup(&self) -> Option { let kw = self.services.kw.as_ref()?; match kw.get_status().await { Ok(snapshot) if matches!(snapshot.job, KwJobStatus::Running { .. }) => { - Some(popup::AppPopup::info( + Some(models::popup::AppPopup::info( "Patchset Apply Blocked", " A kw job is running on the kernel tree.\n\nApplying a patchset now would rewrite the branch the job is building under it.\n\nWait for the job to finish, then apply again.", )) @@ -508,7 +507,10 @@ impl App { /// Runs the git-am apply and maps the outcome to the result popup, /// recording the apply in the kw history on success. - async fn apply_patchset_popup(&self, details: &PatchsetDetailsState) -> popup::AppPopup { + async fn apply_patchset_popup( + &self, + details: &PatchsetDetailsState, + ) -> models::popup::AppPopup { let request = Self::build_apply_patchset_request(details); let action_service = PatchsetActionService::new( &*self.services.fs, @@ -560,9 +562,9 @@ impl App { } } }; - popup::AppPopup::info("Patchset Apply Success", popup_body) + models::popup::AppPopup::info("Patchset Apply Success", popup_body) } - Err(msg) => popup::AppPopup::info("Patchset Apply Fail", msg), + Err(msg) => models::popup::AppPopup::info("Patchset Apply Fail", msg), } } diff --git a/src/app/models/kw_ops.rs b/src/app/models/kw_ops.rs index 4faddd8..5000e17 100644 --- a/src/app/models/kw_ops.rs +++ b/src/app/models/kw_ops.rs @@ -1,4 +1,4 @@ -use crate::{config::KernelTree, kw::readiness::KwReadiness}; +use crate::{config::KernelTree, kw::models::readiness::KwReadiness}; /// Which editable KwOps field is focused. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/src/app/popup.rs b/src/app/popup.rs index 4c92462..bc95249 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -11,7 +11,7 @@ use crate::{ lore::domain::patch::Author, }; -pub use crate::app::models::popup::{AppHelpBuilder, AppPopup, ConfirmAction}; +use crate::app::models::popup::{AppHelpBuilder, AppPopup, ConfirmAction}; impl AppPopup { /// Create an informational text popup. diff --git a/src/app/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index b316815..239ca13 100644 --- a/src/app/screens/kw_ops.rs +++ b/src/app/screens/kw_ops.rs @@ -1,6 +1,6 @@ -use crate::{config::KernelTree, kw::readiness::KwReadiness}; +use crate::{config::KernelTree, kw::models::readiness::KwReadiness}; -pub use crate::app::models::kw_ops::{DeployStartKind, KwOpsFocus, KwOpsState}; +use crate::app::models::kw_ops::{KwOpsFocus, KwOpsState}; impl KwOpsState { pub fn new( @@ -110,11 +110,12 @@ impl KwOpsState { #[cfg(test)] mod tests { use super::*; - use crate::kw::readiness::{ + use crate::app::models::kw_ops::DeployStartKind; + use crate::kw::models::readiness::{ BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, TreeReadiness, }; - use crate::kw::remote::RemoteRefusal; + use crate::kw::models::remote::RemoteRefusal; fn sample_tree() -> KernelTree { serde_json::from_value(serde_json::json!({ diff --git a/src/app/screens/latest.rs b/src/app/screens/latest.rs index 0d59b24..f88f618 100644 --- a/src/app/screens/latest.rs +++ b/src/app/screens/latest.rs @@ -1,7 +1,7 @@ use color_eyre::{eyre::bail, Result}; use crate::lore::{ - application::{cache::CacheMode, errors::LoreError, handle::LoreApiHandle}, + application::{errors::LoreError, handle::LoreApiHandle, models::cache::CacheMode}, domain::patch::Patch, }; @@ -116,7 +116,7 @@ mod tests { use crate::{ infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, lore::{ - application::{actor::LoreApiActor, cache::CacheTtl, service::LoreService}, + application::{actor::LoreApiActor, models::cache::CacheTtl, service::LoreService}, infrastructure::{ http_lore_client::{ LoreHttpError, MockFeedGateway, MockListsGateway, MockPatchHtmlGateway, diff --git a/src/app/screens/mail_list.rs b/src/app/screens/mail_list.rs index 66cc72c..6306597 100644 --- a/src/app/screens/mail_list.rs +++ b/src/app/screens/mail_list.rs @@ -1,7 +1,7 @@ use color_eyre::{eyre::bail, Result}; use crate::lore::{ - application::{cache::CacheMode, handle::LoreApiHandle}, + application::{handle::LoreApiHandle, models::cache::CacheMode}, domain::mailing_list::MailingList, }; diff --git a/src/app/state.rs b/src/app/state.rs index 56b8420..3b206e8 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -2,10 +2,10 @@ use std::collections::{HashMap, HashSet}; use crate::{ app::{ - popup::AppPopup, + models::{kw_ops::KwOpsState, popup::AppPopup}, screens::{ bookmarked::BookmarkedPatchsetsState, details_actions::PatchsetDetailsState, - edit_config::EditConfigState, kw_ops::KwOpsState, latest::LatestPatchsetsState, + edit_config::EditConfigState, latest::LatestPatchsetsState, mail_list::MailingListSelectionState, CurrentScreen, }, }, diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 47c2f94..66351c2 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -10,18 +10,20 @@ use ansi_to_tui::IntoText; use super::{ - popup::AppPopup, - screens::{details_actions::PatchsetAction, kw_ops::KwOpsFocus, CurrentScreen}, + models::{kw_ops::KwOpsFocus, popup::AppPopup}, + screens::{details_actions::PatchsetAction, CurrentScreen}, state::AppState, }; use crate::kw::{ argv, - readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, - remote::{KwRemote, RemoteRefusal}, + models::{ + readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, + remote::{KwRemote, RemoteRefusal}, + }, status::{KwJobStatus, KwPhase, KwStatusSnapshot}, }; -pub use crate::app::models::view_model::{ +use crate::app::models::view_model::{ AppViewModel, BookmarkedViewModel, ConfigEntryRow, EditConfigViewModel, KwOpsViewModel, LatestPatchsetsViewModel, MailingListEntry, MailingListSelectionViewModel, PatchSummaryRow, PatchsetDetailsViewModel, PopupViewBody, PopupViewModel, ScreenViewModel, TagTrailerCounts, @@ -481,7 +483,7 @@ fn compact_deploy_alone_refusal(reason: &DeployAloneRefusal) -> &'static str { } } -fn format_kw_binary(probe: &crate::kw::readiness::KwBinaryProbe) -> String { +fn format_kw_binary(probe: &crate::kw::models::readiness::KwBinaryProbe) -> String { if !probe.available { return "not on PATH".to_string(); } @@ -739,7 +741,7 @@ mod tests { fn kw_ops_command_strips_reserved_extras() { let mut state = app_state_with_kw(None); state.navigation.current_screen = CurrentScreen::KwOps; - let mut ops = crate::app::screens::kw_ops::KwOpsState::new( + let mut ops = crate::app::models::kw_ops::KwOpsState::new( "[PATCH] test".to_string(), "http://lore.example/123".to_string(), "linux".to_string(), @@ -748,20 +750,20 @@ mod tests { "branch": "main" })) .unwrap(), - crate::kw::readiness::KwReadiness { - kw_binary: crate::kw::readiness::KwBinaryProbe { + crate::kw::models::readiness::KwReadiness { + kw_binary: crate::kw::models::readiness::KwBinaryProbe { available: true, version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::readiness::KwVersionCheck::Meets, + check: crate::kw::models::readiness::KwVersionCheck::Meets, }, - tree: crate::kw::readiness::TreeReadiness::Ready { + tree: crate::kw::models::readiness::TreeReadiness::Ready { arch: Some("x86_64".to_string()), }, output_dir: None, - deploy_alone: Err(crate::kw::readiness::DeployAloneRefusal::NoBuildRecord), + deploy_alone: Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord), current_branch: Some("feature".to_string()), - deploy_remote: Err(crate::kw::remote::RemoteRefusal::NoRemotesConfigured), - boot_once: crate::kw::readiness::BootOnceState::Unknown, + deploy_remote: Err(crate::kw::models::remote::RemoteRefusal::NoRemotesConfigured), + boot_once: crate::kw::models::readiness::BootOnceState::Unknown, }, ); ops.extra_args = "--verbose --clean --from-sha abc --doc".to_string(); @@ -783,8 +785,8 @@ mod tests { assert_eq!("unavailable (no remotes configured)", vm.build_deploy_label); } - fn sample_kw_ops(branch: Option<&str>) -> crate::app::screens::kw_ops::KwOpsState { - crate::app::screens::kw_ops::KwOpsState::new( + fn sample_kw_ops(branch: Option<&str>) -> crate::app::models::kw_ops::KwOpsState { + crate::app::models::kw_ops::KwOpsState::new( "[PATCH] test".to_string(), "http://lore.example/123".to_string(), "linux".to_string(), @@ -793,20 +795,20 @@ mod tests { "branch": "main" })) .unwrap(), - crate::kw::readiness::KwReadiness { - kw_binary: crate::kw::readiness::KwBinaryProbe { + crate::kw::models::readiness::KwReadiness { + kw_binary: crate::kw::models::readiness::KwBinaryProbe { available: true, version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::readiness::KwVersionCheck::Meets, + check: crate::kw::models::readiness::KwVersionCheck::Meets, }, - tree: crate::kw::readiness::TreeReadiness::Ready { + tree: crate::kw::models::readiness::TreeReadiness::Ready { arch: Some("x86_64".to_string()), }, output_dir: None, - deploy_alone: Err(crate::kw::readiness::DeployAloneRefusal::NoBuildRecord), + deploy_alone: Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord), current_branch: branch.map(str::to_string), - deploy_remote: Err(crate::kw::remote::RemoteRefusal::NoRemotesConfigured), - boot_once: crate::kw::readiness::BootOnceState::Unknown, + deploy_remote: Err(crate::kw::models::remote::RemoteRefusal::NoRemotesConfigured), + boot_once: crate::kw::models::readiness::BootOnceState::Unknown, }, ) } @@ -884,8 +886,8 @@ mod tests { ); } - fn sample_remote() -> crate::kw::remote::KwRemote { - crate::kw::remote::KwRemote { + fn sample_remote() -> crate::kw::models::remote::KwRemote { + crate::kw::models::remote::KwRemote { name: "dut".to_string(), hostname: "box".to_string(), port: 22, @@ -899,8 +901,9 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; let mut ops = sample_kw_ops(Some("feature")); ops.readiness.deploy_remote = Ok(sample_remote()); - ops.readiness.deploy_alone = Err(crate::kw::readiness::DeployAloneRefusal::NoBuildRecord); - ops.readiness.boot_once = crate::kw::readiness::BootOnceState::On; + ops.readiness.deploy_alone = + Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord); + ops.readiness.boot_once = crate::kw::models::readiness::BootOnceState::On; state.kw.ops = Some(ops); let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { @@ -923,7 +926,7 @@ mod tests { let mut ops = sample_kw_ops(Some("feature")); ops.readiness.deploy_remote = Ok(sample_remote()); ops.readiness.deploy_alone = Ok(()); - ops.readiness.boot_once = crate::kw::readiness::BootOnceState::Off; + ops.readiness.boot_once = crate::kw::models::readiness::BootOnceState::Off; ops.extra_args = "--verbose --local --ccache".to_string(); state.kw.ops = Some(ops); @@ -946,7 +949,7 @@ mod tests { let mut ops = sample_kw_ops(Some("feature")); ops.readiness.deploy_remote = Ok(sample_remote()); ops.readiness.deploy_alone = Ok(()); - ops.readiness.boot_once = crate::kw::readiness::BootOnceState::Unknown; + ops.readiness.boot_once = crate::kw::models::readiness::BootOnceState::Unknown; ops.boot_once_acknowledged = true; state.kw.ops = Some(ops); diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index 080988c..8b3ce29 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -17,10 +17,13 @@ use crate::{ }, kw::{ errors::KwStartError, - history::{KwApplyRecord, KwBuildRecord, MockKwHistoryStore}, + history::MockKwHistoryStore, messages::{DeployOptions, StartRequest}, - readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, - remote::RemoteRefusal, + models::{ + history::{KwApplyRecord, KwBuildRecord}, + readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, + remote::RemoteRefusal, + }, status::{KwJobKind, KwJobStatus, KwPhase}, }, }; diff --git a/src/kw/actor/deploy_test.rs b/src/kw/actor/deploy_test.rs index 4c7b007..98fe8e3 100644 --- a/src/kw/actor/deploy_test.rs +++ b/src/kw/actor/deploy_test.rs @@ -1,5 +1,6 @@ use crate::kw::{ - history::MockKwHistoryStore, readiness::DeployAloneRefusal, remote::RemoteRefusal, + history::MockKwHistoryStore, + models::{readiness::DeployAloneRefusal, remote::RemoteRefusal}, }; use super::{actor_test::*, *}; diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 8186ac0..50ae6e2 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -38,12 +38,15 @@ use crate::{ argv, errors::{KwError, KwStartError, TreeGitError}, handle::KwHandle, - history::{KwBuildRecord, KwHistoryStore}, + history::KwHistoryStore, log_scan, messages::{DeployOptions, KwMessage, StartRequest}, - models::job::{JobDeploy, JobEvent, JobOutcome, JobState, RestoreContext}, - readiness::{self, BootOnceState, KwReadiness, KwVersionCheck, TreeReadiness}, - remote, + models::{ + history::KwBuildRecord, + job::{JobDeploy, JobEvent, JobOutcome, JobState, RestoreContext}, + readiness::{BootOnceState, KwReadiness, KwVersionCheck, TreeReadiness}, + }, + readiness, remote, status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, }, }; diff --git a/src/kw/errors.rs b/src/kw/errors.rs index 212d26d..c651c99 100644 --- a/src/kw/errors.rs +++ b/src/kw/errors.rs @@ -4,7 +4,7 @@ use thiserror::Error; use crate::infrastructure::process::ProcessError; use crate::{ infrastructure::{file_system::FileSystemError, shell::ShellError}, - kw::{ + kw::models::{ readiness::{DeployAloneRefusal, KwReadinessError, TreeReadiness}, remote::RemoteRefusal, }, diff --git a/src/kw/handle.rs b/src/kw/handle.rs index d2a1b26..0f0e3b8 100644 --- a/src/kw/handle.rs +++ b/src/kw/handle.rs @@ -4,9 +4,8 @@ use crate::{ config::KernelTree, kw::{ errors::{KwError, KwStartError}, - history::KwApplyRecord, messages::{KwMessage, StartRequest}, - readiness::KwReadiness, + models::{history::KwApplyRecord, readiness::KwReadiness}, status::KwStatusSnapshot, }, }; diff --git a/src/kw/history.rs b/src/kw/history.rs index c6f8cd7..3931128 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -11,9 +11,9 @@ use std::{collections::HashMap, io, path::Path, sync::Arc}; use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait, JsonUtils}; -pub use crate::kw::models::history::KwApplyRecord; +use crate::kw::models::history::KwApplyRecord; #[cfg(unix)] -pub use crate::kw::models::history::KwBuildRecord; +use crate::kw::models::history::KwBuildRecord; pub const APPLY_HISTORY_FILENAME: &str = "kw_apply_history.json"; #[cfg(unix)] diff --git a/src/kw/messages.rs b/src/kw/messages.rs index 3fbd1dc..6675165 100644 --- a/src/kw/messages.rs +++ b/src/kw/messages.rs @@ -4,8 +4,7 @@ use crate::{ config::KernelTree, kw::{ errors::{KwError, KwStartError}, - history::KwApplyRecord, - readiness::KwReadiness, + models::{history::KwApplyRecord, readiness::KwReadiness}, status::KwStatusSnapshot, }, }; diff --git a/src/kw/mod.rs b/src/kw/mod.rs index 8d87ec3..dd24e5d 100644 --- a/src/kw/mod.rs +++ b/src/kw/mod.rs @@ -10,7 +10,7 @@ pub mod history; #[cfg(unix)] pub mod log_scan; pub mod messages; -pub mod models; +pub(crate) mod models; pub mod readiness; pub mod remote; pub mod status; diff --git a/src/kw/readiness/mod.rs b/src/kw/readiness/mod.rs index 74de86c..cac6f53 100644 --- a/src/kw/readiness/mod.rs +++ b/src/kw/readiness/mod.rs @@ -16,10 +16,7 @@ use crate::infrastructure::{ shell::{ShellCommand, ShellTrait}, }; -pub use crate::kw::models::readiness::{ - BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwReadinessError, - KwVersionCheck, TreeReadiness, -}; +use crate::kw::models::readiness::{KwBinaryProbe, KwVersionCheck}; #[cfg(unix)] mod unix; @@ -101,8 +98,12 @@ mod tests { file_system::{FileSystemError, MockFileSystemTrait, OsFileSystem}, shell::{MockShellTrait, ShellOutput}, }; - use crate::kw::history::{FileKwHistoryStore, KwBuildRecord, KwHistoryStore}; - use crate::kw::remote::RemoteRefusal; + use crate::kw::models::readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}; + use crate::kw::models::remote::RemoteRefusal; + use crate::kw::{ + history::{FileKwHistoryStore, KwHistoryStore}, + models::history::KwBuildRecord, + }; use super::*; diff --git a/src/kw/readiness/unix.rs b/src/kw/readiness/unix.rs index a562e45..5e92a6e 100644 --- a/src/kw/readiness/unix.rs +++ b/src/kw/readiness/unix.rs @@ -11,15 +11,14 @@ use crate::infrastructure::{ }; use crate::{ config::KernelTree, - kw::{ - history::{KwBuildRecord, KwHistoryStore}, - remote, - }, + kw::{history::KwHistoryStore, models::history::KwBuildRecord, remote}, }; -use super::{ - BootOnceState, DeployAloneRefusal, KwReadiness, KwReadinessError, ReadinessService, - TreeReadiness, +use crate::kw::{ + models::readiness::{ + BootOnceState, DeployAloneRefusal, KwReadiness, KwReadinessError, TreeReadiness, + }, + readiness::ReadinessService, }; impl ReadinessService { diff --git a/src/kw/remote/mod.rs b/src/kw/remote/mod.rs index ae244ff..1c2566c 100644 --- a/src/kw/remote/mod.rs +++ b/src/kw/remote/mod.rs @@ -12,9 +12,7 @@ //! to the home copy would silently deploy to a different machine than the //! tree is configured for. -#[cfg(unix)] -pub use crate::kw::models::remote::ParsedRemoteConfig; -pub use crate::kw::models::remote::{KwRemote, RemoteRefusal}; +use crate::kw::models::remote::KwRemote; #[cfg(unix)] mod unix; @@ -46,6 +44,7 @@ mod tests { }; use super::*; + use crate::kw::models::remote::RemoteRefusal; /// Example remote.config: two hosts, default on the second, fields indented. const SAMPLE_REMOTE_CONFIG: &str = "\ diff --git a/src/kw/remote/unix.rs b/src/kw/remote/unix.rs index 0c30744..3469295 100644 --- a/src/kw/remote/unix.rs +++ b/src/kw/remote/unix.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; -use super::{KwRemote, ParsedRemoteConfig, RemoteRefusal}; +use crate::kw::models::remote::{KwRemote, ParsedRemoteConfig, RemoteRefusal}; struct HostBuilder { name: String, diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index 0280bda..89a6f4b 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -259,8 +259,8 @@ mod tests { infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, lore::{ application::{ - cache::{CacheMode, CacheTtl}, handle::LoreApiHandle, + models::cache::{CacheMode, CacheTtl}, }, domain::mailing_list::MailingList, infrastructure::{ diff --git a/src/lore/application/cache.rs b/src/lore/application/cache.rs index 9ea248d..354a786 100644 --- a/src/lore/application/cache.rs +++ b/src/lore/application/cache.rs @@ -8,9 +8,8 @@ use crate::lore::{ domain::{mailing_list::MailingList, patch::Patch, patchset::PatchFeedIndex}, }; -pub use crate::lore::application::models::cache::{ - BootstrapLoreData, CacheMode, CacheTtl, FeedCacheEntry, LoreCache, MailingListsCacheEntry, - PatchsetCacheEntry, PatchsetCacheKey, +use crate::lore::application::models::cache::{ + FeedCacheEntry, LoreCache, MailingListsCacheEntry, PatchsetCacheEntry, PatchsetCacheKey, }; // ── Mailing lists cache ─────────────────────────────────────────────────────── diff --git a/src/lore/application/handle.rs b/src/lore/application/handle.rs index 292e6b8..61bc60e 100644 --- a/src/lore/application/handle.rs +++ b/src/lore/application/handle.rs @@ -9,10 +9,10 @@ use crate::{ infrastructure::shell::ShellCommand, lore::{ application::{ - cache::{BootstrapLoreData, CacheMode}, dto::PatchsetDetails, errors::LoreError, messages::{LoreApiMessage, LoreApiResult}, + models::cache::{BootstrapLoreData, CacheMode}, }, domain::{mailing_list::MailingList, patch::Patch}, }, diff --git a/src/lore/application/messages.rs b/src/lore/application/messages.rs index 81ba640..5512dec 100644 --- a/src/lore/application/messages.rs +++ b/src/lore/application/messages.rs @@ -9,9 +9,9 @@ use crate::{ infrastructure::shell::ShellCommand, lore::{ application::{ - cache::{BootstrapLoreData, CacheMode}, dto::PatchsetDetails, errors::LoreError, + models::cache::{BootstrapLoreData, CacheMode}, }, domain::{mailing_list::MailingList, patch::Patch}, }, diff --git a/src/lore/application/mod.rs b/src/lore/application/mod.rs index e2d4c54..500e49b 100644 --- a/src/lore/application/mod.rs +++ b/src/lore/application/mod.rs @@ -4,5 +4,5 @@ pub mod dto; pub mod errors; pub mod handle; pub mod messages; -pub mod models; +pub(crate) mod models; pub mod service; diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index b1f13a6..55ddb81 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -13,12 +13,12 @@ use crate::{ }, lore::{ application::{ - cache::{ + dto::{PatchTagSummary, PatchsetDetails}, + errors::LoreError, + models::cache::{ BootstrapLoreData, CacheMode, CacheTtl, FeedCacheEntry, LoreCache, MailingListsCacheEntry, PatchsetCacheEntry, PatchsetCacheKey, }, - dto::{PatchTagSummary, PatchsetDetails}, - errors::LoreError, }, domain::{ mailing_list::MailingList, @@ -474,7 +474,7 @@ mod tests { use crate::{ infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, lore::{ - application::cache::{ + application::models::cache::{ CacheTtl, FeedCacheEntry, MailingListsCacheEntry, PatchsetCacheEntry, PatchsetCacheKey, }, diff --git a/src/main.rs b/src/main.rs index 13063ae..1fced14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,7 @@ use infrastructure::{ use input::{actor::InputActor, event::InputEvent}; use kw::history::{FileKwHistoryStore, KwHistoryStore}; use lore::{ - application::{actor::LoreApiActor, cache::CacheTtl, service::LoreService}, + application::{actor::LoreApiActor, models::cache::CacheTtl, service::LoreService}, infrastructure::{ http_lore_client::HttpLoreGateway, patchset_fetcher::B4PatchsetFetcher, diff --git a/src/ui/actor.rs b/src/ui/actor.rs index 2f887a8..264f389 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -1,12 +1,12 @@ //! UI presentation actor: builds [`UiScene`](crate::ui::scene::UiScene) values -//! from [`AppViewModel`](crate::app::view_model::AppViewModel) on a dedicated task. +//! from [`AppViewModel`](crate::app::models::view_model::AppViewModel) on a dedicated task. //! //! [`AppActor`](crate::app::actor::AppActor) calls //! [`UiHandle::build_scene`](crate::ui::handle::UiHandle::build_scene) each frame; //! this actor runs [`UiCore::build_scene`](crate::ui::core::UiCore::build_scene) //! and returns an owned scene for the terminal draw path. Presentation logic stays //! out of [`AppState`](crate::app::state::AppState): the app layer projects domain -//! state into [`AppViewModel`](crate::app::view_model::AppViewModel) before +//! state into [`AppViewModel`](crate::app::models::view_model::AppViewModel) before //! crossing this boundary. use std::{mem, ops::ControlFlow}; @@ -84,7 +84,7 @@ mod tests { use tokio::sync::mpsc; use crate::{ - app::view_model::{ + app::models::view_model::{ AppViewModel, MailingListSelectionViewModel, ScreenViewModel, TargetListStatus, }, ui::{errors::UiError, handle::UiHandle}, diff --git a/src/ui/core.rs b/src/ui/core.rs index d28a836..1a87cfa 100644 --- a/src/ui/core.rs +++ b/src/ui/core.rs @@ -4,7 +4,7 @@ //! dispatching to per-screen builders and composing the navigation bar and //! optional popup. -use crate::app::view_model::{AppViewModel, ScreenViewModel}; +use crate::app::models::view_model::{AppViewModel, ScreenViewModel}; use crate::ui::{ errors::UiError, scene::{NavigationBarScene, UiBody, UiScene}, @@ -113,7 +113,7 @@ impl Default for UiCore { #[cfg(test)] mod tests { - use crate::app::view_model::{ + use crate::app::models::view_model::{ AppViewModel, MailingListSelectionViewModel, ScreenViewModel, TargetListStatus, }; diff --git a/src/ui/handle.rs b/src/ui/handle.rs index fd68c69..ad3d3d0 100644 --- a/src/ui/handle.rs +++ b/src/ui/handle.rs @@ -1,7 +1,7 @@ use tokio::sync::{mpsc, oneshot}; use crate::{ - app::view_model::AppViewModel, + app::models::view_model::AppViewModel, ui::{ errors::UiError, messages::{UiMessage, UiResult}, diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 4f6d785..c675f8d 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -1,7 +1,7 @@ use tokio::sync::oneshot; use crate::{ - app::view_model::AppViewModel, + app::models::view_model::AppViewModel, ui::{errors::UiError, scene::UiScene}, }; diff --git a/src/ui/scene.rs b/src/ui/scene.rs index f420261..aa53c8b 100644 --- a/src/ui/scene.rs +++ b/src/ui/scene.rs @@ -7,9 +7,7 @@ use ratatui::text::{Span, Text}; -// Shared presentation-row types live in the app view-model layer. Re-export -// them here so callers within `ui/` only import from `scene`. -pub use crate::app::view_model::{ +use crate::app::models::view_model::{ ConfigEntryRow, MailingListEntry, PatchSummaryRow, TagTrailerCounts, }; diff --git a/src/ui/screens/bookmarked.rs b/src/ui/screens/bookmarked.rs index e87ced9..69bf577 100644 --- a/src/ui/screens/bookmarked.rs +++ b/src/ui/screens/bookmarked.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::{app::view_model::BookmarkedViewModel, ui::scene::BookmarkedScene}; +use crate::{app::models::view_model::BookmarkedViewModel, ui::scene::BookmarkedScene}; pub struct BookmarkedPainter; diff --git a/src/ui/screens/details.rs b/src/ui/screens/details.rs index 3dda29f..d3ec832 100644 --- a/src/ui/screens/details.rs +++ b/src/ui/screens/details.rs @@ -7,8 +7,8 @@ use ratatui::{ }; use crate::{ - app::view_model::PatchsetDetailsViewModel, - ui::scene::{PatchsetDetailsScene, TagTrailerCounts}, + app::models::view_model::{PatchsetDetailsViewModel, TagTrailerCounts}, + ui::scene::PatchsetDetailsScene, }; pub struct DetailsPainter; diff --git a/src/ui/screens/edit_config.rs b/src/ui/screens/edit_config.rs index 1a04bda..4956e92 100644 --- a/src/ui/screens/edit_config.rs +++ b/src/ui/screens/edit_config.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::{app::view_model::EditConfigViewModel, ui::scene::EditConfigScene}; +use crate::{app::models::view_model::EditConfigViewModel, ui::scene::EditConfigScene}; pub struct EditConfigPainter; @@ -97,7 +97,7 @@ impl EditConfigPainter { #[cfg(test)] mod tests { use super::*; - use crate::app::view_model::EditConfigViewModel; + use crate::app::models::view_model::EditConfigViewModel; fn vm(is_editing_mode: bool, editing_tree_selector: bool) -> EditConfigViewModel { EditConfigViewModel { diff --git a/src/ui/screens/kw_ops.rs b/src/ui/screens/kw_ops.rs index dcdd515..7be00a3 100644 --- a/src/ui/screens/kw_ops.rs +++ b/src/ui/screens/kw_ops.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::{app::view_model::KwOpsViewModel, ui::scene::KwOpsScene}; +use crate::{app::models::view_model::KwOpsViewModel, ui::scene::KwOpsScene}; pub struct KwOpsPainter; diff --git a/src/ui/screens/latest.rs b/src/ui/screens/latest.rs index 35183f0..6550af2 100644 --- a/src/ui/screens/latest.rs +++ b/src/ui/screens/latest.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::{app::view_model::LatestPatchsetsViewModel, ui::scene::LatestScene}; +use crate::{app::models::view_model::LatestPatchsetsViewModel, ui::scene::LatestScene}; pub struct LatestPainter; diff --git a/src/ui/screens/mailing_list.rs b/src/ui/screens/mailing_list.rs index 74f6f77..092db4a 100644 --- a/src/ui/screens/mailing_list.rs +++ b/src/ui/screens/mailing_list.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use crate::{ - app::view_model::{MailingListSelectionViewModel, TargetListStatus}, + app::models::view_model::{MailingListSelectionViewModel, TargetListStatus}, ui::scene::MailingListScene, }; diff --git a/src/ui/screens/popup.rs b/src/ui/screens/popup.rs index f99a1db..31f31a9 100644 --- a/src/ui/screens/popup.rs +++ b/src/ui/screens/popup.rs @@ -7,7 +7,7 @@ use ratatui::{ }; use crate::{ - app::view_model::{PopupViewBody, PopupViewModel}, + app::models::view_model::{PopupViewBody, PopupViewModel}, ui::scene::{PopupBody, PopupScene}, }; From 0cb25002d091693b0a705895842785e620eb46e2 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 08:41:33 -0300 Subject: [PATCH 33/61] refactor: name actor reply log messages once per actor Signed-off-by: lorenzoberts --- src/config/actor.rs | 9 ++++++--- src/kw/actor/mod.rs | 36 +++++++++++++++++++---------------- src/lore/application/actor.rs | 35 ++++++++++++++++++---------------- src/render/actor.rs | 7 +++++-- src/terminal/actor.rs | 31 ++++++++++++++++-------------- src/ui/actor.rs | 7 +++++-- 6 files changed, 72 insertions(+), 53 deletions(-) diff --git a/src/config/actor.rs b/src/config/actor.rs index 97a1a2e..00591de 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -20,6 +20,9 @@ use crate::{ pub const DEFAULT_CONFIG_CHANNEL_SIZE: usize = 16; +const REQUEST_FAILED_LOG: &str = "config request failed"; +const REPLY_DROPPED_LOG: &str = "config reply receiver dropped before response"; + pub struct ConfigActor { state: ConfigState, repo: JsonConfigRepository, @@ -71,7 +74,7 @@ where ConfigMessage::GetSnapshot { reply } => { ActorReplyService::deliver_value( message_name, - "config reply receiver dropped before response", + REPLY_DROPPED_LOG, reply, self.state.to_snapshot(), ); @@ -81,8 +84,8 @@ where let result = self.apply(*draft); ActorReplyService::send_actor_reply( message_name, - "config request failed", - "config reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 50ae6e2..f8e9b43 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -53,6 +53,10 @@ use crate::{ pub const DEFAULT_KW_CHANNEL_SIZE: usize = 16; +const REQUEST_FAILED_LOG: &str = "kw request failed"; +const START_REQUEST_REFUSED_LOG: &str = "kw start request refused"; +const REPLY_DROPPED_LOG: &str = "kw reply receiver dropped before response"; + /// Grace periods for the cancel escalation ladder: SIGTERM the process /// group, wait, SIGKILL, wait, then give up. Giving up still terminates the /// job from the actor's point of view — a group that ignores both signals @@ -154,8 +158,8 @@ impl KwActor { KwMessage::RecordApply { record, reply } => { ActorReplyService::send_actor_reply( message_name, - "kw request failed", - "kw reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, self.history.record_apply(record).map_err(KwError::from), ); @@ -164,8 +168,8 @@ impl KwActor { KwMessage::StartBuild { request, reply } => { ActorReplyService::send_actor_reply( message_name, - "kw start request refused", - "kw reply receiver dropped before response", + START_REQUEST_REFUSED_LOG, + REPLY_DROPPED_LOG, reply, self.start_job(KwJobKind::Build, request).await, ); @@ -174,8 +178,8 @@ impl KwActor { KwMessage::StartDeploy { request, reply } => { ActorReplyService::send_actor_reply( message_name, - "kw start request refused", - "kw reply receiver dropped before response", + START_REQUEST_REFUSED_LOG, + REPLY_DROPPED_LOG, reply, self.start_job(KwJobKind::Deploy, request).await, ); @@ -184,8 +188,8 @@ impl KwActor { KwMessage::StartBuildThenDeploy { request, reply } => { ActorReplyService::send_actor_reply( message_name, - "kw start request refused", - "kw reply receiver dropped before response", + START_REQUEST_REFUSED_LOG, + REPLY_DROPPED_LOG, reply, self.start_job(KwJobKind::BuildThenDeploy, request).await, ); @@ -194,8 +198,8 @@ impl KwActor { KwMessage::Cancel { reply } => { ActorReplyService::send_actor_reply( message_name, - "kw request failed", - "kw reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, self.request_cancel(), ); @@ -204,7 +208,7 @@ impl KwActor { KwMessage::GetStatus { reply } => { ActorReplyService::deliver_value( message_name, - "kw reply receiver dropped before response", + REPLY_DROPPED_LOG, reply, self.status_tx.borrow().clone(), ); @@ -213,7 +217,7 @@ impl KwActor { KwMessage::WatchStatus { reply } => { ActorReplyService::deliver_value( message_name, - "kw reply receiver dropped before response", + REPLY_DROPPED_LOG, reply, self.status_tx.subscribe(), ); @@ -227,8 +231,8 @@ impl KwActor { } => { ActorReplyService::send_actor_reply( message_name, - "kw request failed", - "kw reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, self.evaluate_readiness(&kernel_tree_id, &tree, for_branch) .await, @@ -238,8 +242,8 @@ impl KwActor { KwMessage::RestorePreviousBranch { reply } => { ActorReplyService::send_actor_reply( message_name, - "kw request failed", - "kw reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, self.restore_previous_branch().await, ); diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index 89a6f4b..6e6962b 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -16,6 +16,9 @@ use crate::lore::application::{ pub const DEFAULT_LORE_API_CHANNEL_SIZE: usize = 32; +const REQUEST_FAILED_LOG: &str = "lore api request failed"; +const REPLY_DROPPED_LOG: &str = "lore api reply receiver dropped before response"; + pub struct LoreApiActor { core: Option, rx: mpsc::Receiver, @@ -64,8 +67,8 @@ impl LoreApiActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -79,8 +82,8 @@ impl LoreApiActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -108,8 +111,8 @@ impl LoreApiActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -133,8 +136,8 @@ impl LoreApiActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -148,8 +151,8 @@ impl LoreApiActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -163,8 +166,8 @@ impl LoreApiActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -180,8 +183,8 @@ impl LoreApiActor { .await; ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -217,8 +220,8 @@ impl LoreApiActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "lore api request failed", - "lore api reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); diff --git a/src/render/actor.rs b/src/render/actor.rs index 127d5eb..0405003 100644 --- a/src/render/actor.rs +++ b/src/render/actor.rs @@ -19,6 +19,9 @@ use crate::render::{ pub const DEFAULT_RENDER_CHANNEL_SIZE: usize = 32; +const REQUEST_FAILED_LOG: &str = "render request failed"; +const REPLY_DROPPED_LOG: &str = "render reply receiver dropped before response"; + pub struct RenderActor { core: Option>, rx: mpsc::Receiver, @@ -72,8 +75,8 @@ impl RenderActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "render request failed", - "render reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index 45cf093..e5587fe 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -17,6 +17,9 @@ use crate::terminal::{ pub const DEFAULT_TERMINAL_CHANNEL_SIZE: usize = 32; +const REQUEST_FAILED_LOG: &str = "terminal request failed"; +const REPLY_DROPPED_LOG: &str = "terminal reply receiver dropped before response"; + pub struct TerminalActor { session: Option>, rx: mpsc::Receiver, @@ -62,8 +65,8 @@ impl TerminalActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "terminal request failed", - "terminal reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -75,8 +78,8 @@ impl TerminalActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "terminal request failed", - "terminal reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -88,8 +91,8 @@ impl TerminalActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "terminal request failed", - "terminal reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -101,8 +104,8 @@ impl TerminalActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "terminal request failed", - "terminal reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -118,8 +121,8 @@ impl TerminalActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "terminal request failed", - "terminal reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -131,8 +134,8 @@ impl TerminalActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "terminal request failed", - "terminal reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); @@ -144,8 +147,8 @@ impl TerminalActor { .and_then(|result| result); ActorReplyService::send_actor_reply( message_name, - "terminal request failed", - "terminal reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply, result, ); diff --git a/src/ui/actor.rs b/src/ui/actor.rs index 264f389..6f075d1 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -17,6 +17,9 @@ use crate::ui::{core::UiCore, handle::UiHandle, messages::UiMessage}; pub const DEFAULT_UI_CHANNEL_SIZE: usize = 32; +const REQUEST_FAILED_LOG: &str = "ui request failed"; +const REPLY_DROPPED_LOG: &str = "ui reply receiver dropped before response"; + pub struct UiActor { core: UiCore, rx: mpsc::Receiver, @@ -64,8 +67,8 @@ impl UiActor { tracing::debug!(ok = result.is_ok(), "ui scene built"); ActorReplyService::send_actor_reply( message_name, - "ui request failed", - "ui reply receiver dropped before response", + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, reply_to, result, ); From 225454ea2b9916c649fc42aec9112a179bcb3ea9 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 08:42:26 -0300 Subject: [PATCH 34/61] refactor(lore): rename PatchsetParserService to PatchsetTextService The type's methods split a patch into cover and diff, build a reply template, and pull a git send-email command out of the patch page. Reply would describe only the last two; the renderer also uses the cover/diff split, which is not a reply. PatchsetTextService names the shared job of turning patchset text into those three results. Signed-off-by: lorenzoberts --- src/lore/application/service.rs | 8 ++++---- src/lore/infrastructure/patchset_parser.rs | 12 ++++++------ src/render/mod.rs | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index 55ddb81..3cc266e 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -29,7 +29,7 @@ use crate::{ http_lore_client::{FeedGateway, ListsGateway, LoreHttpError, PatchHtmlGateway}, parsers::LoreParserService, patchset_fetcher::PatchsetFetcher, - patchset_parser::{PatchsetParser, PatchsetParserService}, + patchset_parser::{PatchsetParser, PatchsetTextService}, persistence::{MailingListsCacheStore, UserLoreStateStore}, }, }, @@ -345,7 +345,7 @@ impl LoreService { .ok_or_else(|| LoreError::Parse("Message-Id header not found".to_string()))?; let reply_path = tmp_dir.join(format!("{message_id}-reply.mbx")); - let mut reply = PatchsetParserService::generate_reply_template(patch); + let mut reply = PatchsetTextService::generate_reply_template(patch); reply.push_str(&format!("\nReviewed-by: {git_signature}\n")); self.fs .write(&reply_path, reply.as_bytes()) @@ -355,7 +355,7 @@ impl LoreService { .fetch_patch_html(target_list, message_id) .map_err(LoreError::Http)?; - let command = PatchsetParserService::extract_git_reply_command( + let command = PatchsetTextService::extract_git_reply_command( &patch_html, git_send_email_options, &format!("{}", reply_path.display()), @@ -426,7 +426,7 @@ impl LoreService { impl LoreService { fn extract_tag_summary(raw_patch: &str) -> PatchTagSummary { - let (cover, _) = PatchsetParserService::split_cover(raw_patch); + let (cover, _) = PatchsetTextService::split_cover(raw_patch); let mut reviewed_by = HashSet::new(); let mut tested_by = HashSet::new(); diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index 32bd10e..5ad5040 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -103,9 +103,9 @@ impl PatchsetParser for MboxPatchsetParser { } } -pub struct PatchsetParserService; +pub struct PatchsetTextService; -impl PatchsetParserService { +impl PatchsetTextService { /// Splits a raw patch string into `(cover, diff)` at the first `\n---\n` separator. /// /// Everything before (and including) the separator line is the cover; everything @@ -289,7 +289,7 @@ mod tests { #[test] fn split_cover_finds_separator() { let raw = "Subject: test\nTo: list\n\nBody text.\n---\ndiff --git a/foo b/foo"; - let (cover, diff) = PatchsetParserService::split_cover(raw); + let (cover, diff) = PatchsetTextService::split_cover(raw); assert!(cover.contains("Body text.")); assert!(diff.contains("diff --git")); } @@ -297,7 +297,7 @@ mod tests { #[test] fn split_cover_returns_full_patch_when_no_separator() { let raw = "Subject: no diff here\nBody only."; - let (cover, diff) = PatchsetParserService::split_cover(raw); + let (cover, diff) = PatchsetTextService::split_cover(raw); assert_eq!(raw, cover); assert!(diff.is_empty()); } @@ -315,7 +315,7 @@ mod tests { assert_eq!( expected, - PatchsetParserService::generate_reply_template(&patch_sample) + PatchsetTextService::generate_reply_template(&patch_sample) ); } @@ -341,7 +341,7 @@ mod tests { ], }; - let result = PatchsetParserService::extract_git_reply_command( + let result = PatchsetTextService::extract_git_reply_command( &patch_html, "--dry-run --suppress-cc=all", reply_path, diff --git a/src/render/mod.rs b/src/render/mod.rs index de179b4..772ec12 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -17,7 +17,7 @@ use tracing::{event, Level}; use crate::{ infrastructure::shell::ShellTrait, - lore::infrastructure::patchset_parser::PatchsetParserService, + lore::infrastructure::patchset_parser::PatchsetTextService, render::{cover_renderer::CoverRendererService, patch_renderer::PatchRendererService}, }; @@ -41,7 +41,7 @@ impl RenderServiceApi for ShellRenderService { let mut previews = Vec::with_capacity(request.raw_patches.len()); for raw_patch in request.raw_patches { let raw_patch_expanded = raw_patch.replace('\t', " "); - let (raw_cover, raw_diff) = PatchsetParserService::split_cover(&raw_patch_expanded); + let (raw_cover, raw_diff) = PatchsetTextService::split_cover(&raw_patch_expanded); let rendered_cover = match CoverRendererService::render_cover(shell, raw_cover, &request.cover_renderer) { From 427ac868d24b28f8a5103ccca0d2eac9c99d06c8 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 08:43:17 -0300 Subject: [PATCH 35/61] refactor(terminal): inline the key-press wait into the session impl Signed-off-by: lorenzoberts --- src/terminal/session.rs | 40 +++++++++++++++------------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/src/terminal/session.rs b/src/terminal/session.rs index f7a234b..de35af3 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -53,30 +53,6 @@ impl CrosstermTerminalSession { } } -impl CrosstermTerminalSession { - fn wait_for_key_press_from_session( - session: &mut dyn TerminalSessionApi, - key: KeyCode, - timeout: Duration, - ) -> TerminalResult { - let started_at = Instant::now(); - - while started_at.elapsed() < timeout { - let elapsed = started_at.elapsed(); - let remaining = timeout.saturating_sub(elapsed); - let poll_timeout = remaining.min(Duration::from_millis(16)); - - if let Some(TerminalEvent::Key(input)) = session.poll_event(poll_timeout)? { - if input.code == key { - return Ok(true); - } - } - } - - Ok(false) - } -} - impl TerminalSessionApi for CrosstermTerminalSession { fn draw(&mut self, frame: TerminalFrame) -> TerminalResult<()> { match frame { @@ -117,7 +93,21 @@ impl TerminalSessionApi for CrosstermTerminalSession { } fn wait_for_key_press(&mut self, key: KeyCode, timeout: Duration) -> TerminalResult { - Self::wait_for_key_press_from_session(self, key, timeout) + let started_at = Instant::now(); + + while started_at.elapsed() < timeout { + let elapsed = started_at.elapsed(); + let remaining = timeout.saturating_sub(elapsed); + let poll_timeout = remaining.min(Duration::from_millis(16)); + + if let Some(TerminalEvent::Key(input)) = self.poll_event(poll_timeout)? { + if input.code == key { + return Ok(true); + } + } + } + + Ok(false) } fn size(&self) -> TerminalResult<(u16, u16)> { From e64d60cc3277e86c7c5f854db300abcd64fc374d Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 08:44:15 -0300 Subject: [PATCH 36/61] test(kw): split GitStub into public and private impl blocks Signed-off-by: lorenzoberts --- src/kw/actor/actor_test.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index 8b3ce29..84ace13 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -157,14 +157,6 @@ impl GitStub { } } - fn set_dirty(&self, dirty: bool) { - self.dirty.store(dirty, Ordering::Relaxed); - } - - fn fail_switches_to(&self, branch: Option<&str>) { - *self.fail_switch_to.lock().unwrap() = branch.map(str::to_string); - } - pub(super) fn head(&self) -> String { self.head.lock().unwrap().clone() } @@ -210,6 +202,16 @@ impl GitStub { } } +impl GitStub { + fn set_dirty(&self, dirty: bool) { + self.dirty.store(dirty, Ordering::Relaxed); + } + + fn fail_switches_to(&self, branch: Option<&str>) { + *self.fail_switch_to.lock().unwrap() = branch.map(str::to_string); + } +} + /// fs answers for a ready kernel tree with no active kw env: the /// kernel-root probes pass, `.config` exists, `.kw/env.current` is /// absent, `.kw/build.config` is unreadable (arch probes as None), From 1c0f0f79667dbc5e21fb958dbf012399e271a4ad Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 08:46:20 -0300 Subject: [PATCH 37/61] fix(infrastructure): restore the terminal from error hooks only once The eyre hook runs when a Report is constructed, so a failed restore printed "failed to restore terminal" on every eyre!, bail!, and foreign-error conversion, not only on a fatal exit. The panic hook and the eyre hook now share one process-wide guard: restore is attempted once, and a failure is reported once. Signed-off-by: lorenzoberts --- src/infrastructure/errors.rs | 49 +++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/infrastructure/errors.rs b/src/infrastructure/errors.rs index 7ab3911..45b9b7f 100644 --- a/src/infrastructure/errors.rs +++ b/src/infrastructure/errors.rs @@ -2,6 +2,7 @@ use std::{ error::Error, io::{self, Write}, panic, + sync::atomic::{AtomicBool, Ordering}, }; use color_eyre::{ @@ -11,6 +12,29 @@ use color_eyre::{ use super::terminal::restore; +struct TerminalRestoreGuard { + started: AtomicBool, +} + +impl TerminalRestoreGuard { + const fn new() -> Self { + Self { + started: AtomicBool::new(false), + } + } + + fn call(&self, restore: impl FnOnce() -> io::Result<()>) { + if self.started.swap(true, Ordering::SeqCst) { + return; + } + if let Err(err) = restore() { + let _ = writeln!(io::stderr(), "failed to restore terminal: {err}"); + } + } +} + +static TERMINAL_RESTORE: TerminalRestoreGuard = TerminalRestoreGuard::new(); + /// This replaces the standard color_eyre panic and error hooks with hooks that /// restore the terminal before printing the panic or error. /// @@ -23,18 +47,14 @@ pub fn install_hooks() -> Result<()> { // convert from a color_eyre PanicHook to a standard panic hook let panic_hook = panic_hook.into_panic_hook(); panic::set_hook(Box::new(move |panic_info| { - if let Err(err) = restore() { - let _ = writeln!(io::stderr(), "failed to restore terminal: {err}"); - } + TERMINAL_RESTORE.call(restore); panic_hook(panic_info); })); // convert from a color_eyre EyreHook to a eyre ErrorHook let eyre_hook = eyre_hook.into_eyre_hook(); set_hook(Box::new(move |error: &(dyn Error + 'static)| { - if let Err(err) = restore() { - let _ = writeln!(io::stderr(), "failed to restore terminal: {err}"); - } + TERMINAL_RESTORE.call(restore); eyre_hook(error) }))?; @@ -43,6 +63,8 @@ pub fn install_hooks() -> Result<()> { #[cfg(test)] mod tests { + use std::sync::atomic::AtomicUsize; + use super::*; #[test] @@ -65,4 +87,19 @@ mod tests { assert!(result.is_err()); } + + #[test] + fn restore_guard_runs_the_operation_once() { + let guard = TerminalRestoreGuard::new(); + let calls = AtomicUsize::new(0); + let attempt = || { + calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + }; + + guard.call(attempt); + guard.call(attempt); + + assert_eq!(1, calls.load(Ordering::SeqCst)); + } } From ab720ebe68d5d047975c61ddc181ed64d0dd3679 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 08:59:20 -0300 Subject: [PATCH 38/61] refactor: drop non-unix support Signed-off-by: lorenzoberts --- .github/workflows/build_and_unit_test.yml | 34 -- .github/workflows/release.yml | 3 - src/app/integration_tests/kw_ops.rs | 1 - src/app/integration_tests/mod.rs | 1 - src/app/integration_tests/patchset_actions.rs | 9 +- src/app/mod.rs | 9 +- src/app/state.rs | 4 +- src/infrastructure/file_system/mod.rs | 2 - src/infrastructure/file_system/trait.rs | 2 - src/infrastructure/mod.rs | 1 - src/kw/errors.rs | 8 - src/kw/handle.rs | 1 - src/kw/history.rs | 16 +- src/kw/messages.rs | 4 - src/kw/mod.rs | 2 - src/kw/models/mod.rs | 1 - src/kw/models/readiness.rs | 3 - src/kw/models/remote.rs | 2 - src/kw/{readiness/mod.rs => readiness.rs} | 471 +++++++++++++++++- src/kw/readiness/unix.rs | 465 ----------------- src/kw/{remote/mod.rs => remote.rs} | 187 ++++++- src/kw/remote/unix.rs | 183 ------- src/kw/status.rs | 4 - src/main.rs | 7 - 24 files changed, 661 insertions(+), 759 deletions(-) rename src/kw/{readiness/mod.rs => readiness.rs} (69%) delete mode 100644 src/kw/readiness/unix.rs rename src/kw/{remote/mod.rs => remote.rs} (65%) delete mode 100644 src/kw/remote/unix.rs diff --git a/.github/workflows/build_and_unit_test.yml b/.github/workflows/build_and_unit_test.yml index b788c92..0377d7c 100644 --- a/.github/workflows/build_and_unit_test.yml +++ b/.github/workflows/build_and_unit_test.yml @@ -54,37 +54,3 @@ jobs: printf '\e[1;33m\tPLEASE, SOLVE THEM LOCALLY W/ `cargo test`\e[0m\n' printf '\e[1;33m\t==========================================\n\e[0m' exit 1 - - # The kw actor and the process infrastructure it uses are unix-only - # (#[cfg(unix)]). This job exists so a cfg leak — non-gated code - # referencing them — fails mechanically instead of in review. - check-non-unix: - runs-on: ubuntu-latest - timeout-minutes: 4 - if: '!github.event.pull_request.draft' - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Update rustup and install rustc and cargo - shell: bash - run: | - rustup update - rustup install stable - - # rustls/ring needs a C toolchain for the target; windows-gnu builds - # with mingw on Ubuntu, unlike the MSVC target. - - name: Install the mingw cross toolchain - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y gcc-mingw-w64-x86-64 - - - name: Check non-unix compilation - shell: bash - env: - CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc - run: | - rustup target add x86_64-pc-windows-gnu - cargo check --target x86_64-pc-windows-gnu --verbose diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a947bb2..12c1fd4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,9 +110,6 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json steps: - - name: enable windows longpaths - run: | - git config --global core.longpaths true - uses: actions/checkout@v4 with: submodules: recursive diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index 2fcbd22..4874ad0 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -56,7 +56,6 @@ fn assert_info_popup(popup: Option<&AppPopup>, expected_title: &str, fragment: & ); } -#[cfg(unix)] mod unix { use std::{ path::PathBuf, diff --git a/src/app/integration_tests/mod.rs b/src/app/integration_tests/mod.rs index 6f65a51..76e7ad8 100644 --- a/src/app/integration_tests/mod.rs +++ b/src/app/integration_tests/mod.rs @@ -3,7 +3,6 @@ mod flow_errors; mod flow_navigation; mod helpers; mod kw_ops; -#[cfg(unix)] mod kw_status; mod patchset_actions; diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index 553e1f5..01f40aa 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -278,7 +278,7 @@ async fn apply_is_blocked_while_a_kw_job_runs() { log_dir.clone(), ); - let kw = app.services.kw.as_ref().expect("tests run on unix"); + let kw = app.services.kw.as_ref().expect("kw handle is available"); kw.start_build(kw_start_request()).await.unwrap(); app.consolidate_patchset_actions().await.unwrap(); @@ -327,7 +327,12 @@ async fn apply_is_allowed_again_after_the_job_finishes() { log_dir.clone(), ); - let kw = app.services.kw.as_ref().expect("tests run on unix").clone(); + let kw = app + .services + .kw + .as_ref() + .expect("kw handle is available") + .clone(); kw.start_build(kw_start_request()).await.unwrap(); // While the job runs, the apply is blocked. diff --git a/src/app/mod.rs b/src/app/mod.rs index 7daaba6..e304f21 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -80,11 +80,10 @@ pub struct AppServices { pub shell: Box, pub fs: Arc, pub config: ConfigHandle, - /// Direct access to the store, for the non-unix fallback below. + /// Apply and build history. Writes go here when no KwActor is attached. pub kw_history: Arc, - /// `None` on non-unix builds, where ProcessTrait (and thus KwActor) - /// does not exist; apply-history writes then go to `kw_history` - /// directly, as they did before the actor landed. + /// `None` when no KwActor is attached; apply-history writes then go + /// to `kw_history` directly. pub kw: Option, } @@ -539,7 +538,7 @@ impl App { Some(record) => { // History writes go through KwActor so apply // recording serializes with job state; without an - // actor (non-unix), write the store directly. + // actor, write the store directly. let recorded = match &self.services.kw { Some(kw) => kw.record_apply(record).await.map_err(|e| e.to_string()), None => self diff --git a/src/app/state.rs b/src/app/state.rs index 3b206e8..d8b6d6c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -42,8 +42,8 @@ pub struct ConfigUiState { /// Projection of kw job status owned by AppActor, not the job itself. /// -/// `status` is `None` when no KwActor is attached (non-unix, or the -/// watch subscription failed) rather than a fabricated idle job. +/// `status` is `None` when no KwActor is attached or the watch +/// subscription failed, rather than a fabricated idle job. #[derive(Clone, Debug, Default)] pub struct KwUiState { pub status: Option, diff --git a/src/infrastructure/file_system/mod.rs b/src/infrastructure/file_system/mod.rs index 4ec91ad..e66fded 100644 --- a/src/infrastructure/file_system/mod.rs +++ b/src/infrastructure/file_system/mod.rs @@ -7,7 +7,6 @@ pub use r#trait::{FileSystemError, FileSystemTrait}; #[cfg(test)] pub use r#trait::MockFileSystemTrait; -#[cfg(unix)] use std::path::PathBuf; use std::{ fs::{self, File}, @@ -45,7 +44,6 @@ impl FileSystemTrait for OsFileSystem { path.is_dir() } - #[cfg(unix)] fn read_dir(&self, path: &Path) -> Result, FileSystemError> { let mut entries = fs::read_dir(path)? .map(|entry| entry.map(|e| e.path())) diff --git a/src/infrastructure/file_system/trait.rs b/src/infrastructure/file_system/trait.rs index daf86c0..8548dbf 100644 --- a/src/infrastructure/file_system/trait.rs +++ b/src/infrastructure/file_system/trait.rs @@ -1,7 +1,6 @@ use mockall::automock; use thiserror::Error; -#[cfg(unix)] use std::path::PathBuf; use std::{fs::Metadata, io, path::Path}; @@ -22,7 +21,6 @@ pub trait FileSystemTrait: Send + Sync { /// Returns the immediate children of directory `path` as full paths, /// sorted for determinism. Entry kind and metadata are queried /// separately via `is_dir`/`is_file`/`metadata`. - #[cfg(unix)] fn read_dir(&self, path: &Path) -> Result, FileSystemError>; fn rename(&self, from: &Path, to: &Path) -> Result<(), FileSystemError>; fn create_writer(&self, path: &Path) -> Result, FileSystemError>; diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index d9bc4d0..cc96c4e 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -4,7 +4,6 @@ pub mod errors; pub mod file_system; pub mod monitoring; pub mod net; -#[cfg(unix)] pub mod process; pub mod shell; pub mod terminal; diff --git a/src/kw/errors.rs b/src/kw/errors.rs index c651c99..5ab63b9 100644 --- a/src/kw/errors.rs +++ b/src/kw/errors.rs @@ -1,6 +1,5 @@ use thiserror::Error; -#[cfg(unix)] use crate::infrastructure::process::ProcessError; use crate::{ infrastructure::{file_system::FileSystemError, shell::ShellError}, @@ -10,7 +9,6 @@ use crate::{ }, }; -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Error)] pub enum KwError { #[error("kw actor unavailable: {0}")] @@ -38,7 +36,6 @@ pub enum KwError { /// Accept/refuse verdict for `Start*` messages. The reply is always /// immediate: an accepted job keeps running inside the actor after the /// caller has been answered. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Error)] pub enum KwStartError { #[error("kw actor unavailable: {0}")] @@ -65,8 +62,6 @@ pub enum KwStartError { BootOnceNotAcknowledged, #[error("{0}")] DeployAloneRefused(DeployAloneRefusal), - // Spawning a process is unix-only (ProcessTrait is cfg(unix)). - #[cfg(unix)] #[error("failed to spawn the kw process: {0}")] Spawn(#[from] ProcessError), #[error("filesystem error: {0}")] @@ -75,7 +70,6 @@ pub enum KwStartError { /// Shared git-state refusal for the start and restore paths, converted /// into the public error each path reports. -#[cfg(unix)] #[derive(Debug)] pub(crate) enum TreeGitError { DirtyWorktree, @@ -83,7 +77,6 @@ pub(crate) enum TreeGitError { Switch(String), } -#[cfg(unix)] impl From for KwStartError { fn from(error: TreeGitError) -> Self { match error { @@ -94,7 +87,6 @@ impl From for KwStartError { } } -#[cfg(unix)] impl From for KwError { fn from(error: TreeGitError) -> Self { match error { diff --git a/src/kw/handle.rs b/src/kw/handle.rs index 0f0e3b8..c4bd451 100644 --- a/src/kw/handle.rs +++ b/src/kw/handle.rs @@ -16,7 +16,6 @@ pub struct KwHandle { } impl KwHandle { - #[cfg(unix)] pub fn new(tx: mpsc::Sender) -> Self { Self { tx } } diff --git a/src/kw/history.rs b/src/kw/history.rs index 3931128..4ef58a8 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -12,11 +12,9 @@ use std::{collections::HashMap, io, path::Path, sync::Arc}; use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait, JsonUtils}; use crate::kw::models::history::KwApplyRecord; -#[cfg(unix)] use crate::kw::models::history::KwBuildRecord; pub const APPLY_HISTORY_FILENAME: &str = "kw_apply_history.json"; -#[cfg(unix)] pub const BUILD_HISTORY_FILENAME: &str = "kw_build_history.json"; /// message id → kernel tree id → record: applying the same patchset to @@ -26,7 +24,6 @@ type ApplyRecords = HashMap>; /// kernel tree id → branch → record: building several branches of the same /// tree keeps one record per branch, so a failed build on one branch does /// not clobber another branch's successful record. -#[cfg(unix)] type BuildRecords = HashMap>; #[automock] @@ -40,7 +37,6 @@ pub trait KwHistoryStore: Send + Sync { /// it came from. A missing history file is a normal state, not an /// error. Records with unparseable `applied_at` values sort oldest, /// same convention as the build records. - #[cfg(unix)] fn apply_record_for_branch( &self, kernel_tree_id: &str, @@ -49,13 +45,11 @@ pub trait KwHistoryStore: Send + Sync { /// Inserts or replaces the build record for the record's /// `(kernel_tree_id, branch)` pair. - #[cfg(unix)] fn record_build(&self, record: KwBuildRecord) -> Result<(), FileSystemError>; /// Returns the record for `(kernel_tree_id, branch)` and the newest /// record for the tree across branches from a single load of the /// history file — the pair a readiness snapshot is computed from. - #[cfg(unix)] fn build_records( &self, kernel_tree_id: &str, @@ -66,18 +60,16 @@ pub trait KwHistoryStore: Send + Sync { pub struct FileKwHistoryStore { fs: Arc, apply_history_path: String, - #[cfg(unix)] build_history_path: String, } impl FileKwHistoryStore { - /// Creates a store keeping `kw_apply_history.json` under `data_dir`. - /// On Unix it also keeps `kw_build_history.json` there. + /// Creates a store keeping `kw_apply_history.json` and + /// `kw_build_history.json` under `data_dir`. pub fn new(fs: Arc, data_dir: String) -> Self { FileKwHistoryStore { fs, apply_history_path: format!("{data_dir}/{APPLY_HISTORY_FILENAME}"), - #[cfg(unix)] build_history_path: format!("{data_dir}/{BUILD_HISTORY_FILENAME}"), } } @@ -110,7 +102,6 @@ impl FileKwHistoryStore { JsonUtils::atomic_write_json(&*self.fs, &records, &self.apply_history_path) } - #[cfg(unix)] fn store_build_record(&self, record: KwBuildRecord) -> Result<(), FileSystemError> { let mut records: BuildRecords = self.load_records(&self.build_history_path)?; records @@ -133,7 +124,6 @@ impl KwHistoryStore for FileKwHistoryStore { .map_err(|e| self.error_with_path(&self.apply_history_path, e)) } - #[cfg(unix)] fn apply_record_for_branch( &self, kernel_tree_id: &str, @@ -153,13 +143,11 @@ impl KwHistoryStore for FileKwHistoryStore { .map_err(|e| self.error_with_path(&self.apply_history_path, e)) } - #[cfg(unix)] fn record_build(&self, record: KwBuildRecord) -> Result<(), FileSystemError> { self.store_build_record(record) .map_err(|e| self.error_with_path(&self.build_history_path, e)) } - #[cfg(unix)] fn build_records( &self, kernel_tree_id: &str, diff --git a/src/kw/messages.rs b/src/kw/messages.rs index 6675165..25d6690 100644 --- a/src/kw/messages.rs +++ b/src/kw/messages.rs @@ -11,7 +11,6 @@ use crate::{ /// Deploy knobs the actor injects onto `kw deploy`. Build-only starts /// leave [`StartRequest::deploy`] unset. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone)] pub struct DeployOptions { pub reboot: bool, @@ -24,7 +23,6 @@ pub struct DeployOptions { /// Everything the actor needs to start a job. The tree context is resolved /// by the caller from its config snapshot, keeping KwActor decoupled from /// ConfigActor. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone)] pub struct StartRequest { pub kernel_tree_id: String, @@ -42,7 +40,6 @@ pub struct StartRequest { pub deploy: Option, } -#[cfg_attr(not(unix), expect(dead_code))] pub enum KwMessage { RecordApply { record: KwApplyRecord, @@ -91,7 +88,6 @@ pub enum KwMessage { Shutdown { reply: oneshot::Sender<()> }, } -#[cfg(unix)] impl KwMessage { pub fn name(&self) -> &'static str { match self { diff --git a/src/kw/mod.rs b/src/kw/mod.rs index dd24e5d..457a737 100644 --- a/src/kw/mod.rs +++ b/src/kw/mod.rs @@ -1,13 +1,11 @@ //! kw integration: persistence, readiness probes, and the actor //! orchestrating `kw build` / `kw deploy` jobs. -#[cfg(unix)] pub mod actor; pub mod argv; pub mod errors; pub mod handle; pub mod history; -#[cfg(unix)] pub mod log_scan; pub mod messages; pub(crate) mod models; diff --git a/src/kw/models/mod.rs b/src/kw/models/mod.rs index f196d84..3ead4c3 100644 --- a/src/kw/models/mod.rs +++ b/src/kw/models/mod.rs @@ -1,5 +1,4 @@ pub mod history; -#[cfg(unix)] pub(crate) mod job; pub mod readiness; pub mod remote; diff --git a/src/kw/models/readiness.rs b/src/kw/models/readiness.rs index b600fb6..0172858 100644 --- a/src/kw/models/readiness.rs +++ b/src/kw/models/readiness.rs @@ -18,7 +18,6 @@ pub enum KwReadinessError { } /// Readiness of a configured kernel tree for kw operations. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum TreeReadiness { /// Kernel root, kw-initialized, with a `.config`. `arch` is the literal @@ -96,7 +95,6 @@ impl std::fmt::Display for TreeReadiness { /// Why a deploy-without-build was refused. Each variant's message is the /// actionable explanation. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum DeployAloneRefusal { #[error("the kernel tree is not ready: {0}")] @@ -135,7 +133,6 @@ pub enum DeployAloneRefusal { /// value, including a missing key, leaves the option on. [`Unknown`] is /// therefore a confirm-to-proceed gate, same as [`On`]: patch-hub cannot /// pass a CLI off-switch. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BootOnceState { Off, diff --git a/src/kw/models/remote.rs b/src/kw/models/remote.rs index 5c62123..b4ee1a6 100644 --- a/src/kw/models/remote.rs +++ b/src/kw/models/remote.rs @@ -11,7 +11,6 @@ pub struct KwRemote { /// Why a deploy remote could not be resolved. Each variant's message is /// the actionable explanation. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RemoteRefusal { #[error( @@ -33,7 +32,6 @@ pub enum RemoteRefusal { /// Parsed contents of a `remote.config` file. Incomplete Host stanzas /// (no Hostname, or an unparseable Port) are dropped rather than guessed. -#[cfg(unix)] #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ParsedRemoteConfig { pub default: Option, diff --git a/src/kw/readiness/mod.rs b/src/kw/readiness.rs similarity index 69% rename from src/kw/readiness/mod.rs rename to src/kw/readiness.rs index cac6f53..2c4e148 100644 --- a/src/kw/readiness/mod.rs +++ b/src/kw/readiness.rs @@ -11,15 +11,33 @@ //! injected infrastructure traits; KwActor composes them into the //! `GetReadiness` snapshot. -use crate::infrastructure::{ - env::EnvTrait, - shell::{ShellCommand, ShellTrait}, -}; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use crate::kw::models::readiness::{KwBinaryProbe, KwVersionCheck}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + time::SystemTime, +}; -#[cfg(unix)] -mod unix; +use crate::{ + config::KernelTree, + infrastructure::{ + env::EnvTrait, + file_system::{FileSystemError, FileSystemTrait}, + shell::{ShellCommand, ShellTrait}, + }, + kw::{ + history::KwHistoryStore, + models::{ + history::KwBuildRecord, + readiness::{ + BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwReadinessError, + KwVersionCheck, TreeReadiness, + }, + }, + remote, + }, +}; pub struct ReadinessService; @@ -55,6 +73,398 @@ impl ReadinessService { check, } } + + /// Parses kw's `key=value` config format (`.kw/build.config`, + /// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: + /// blank lines and lines starting with `#` are skipped, everything from the + /// last `#` on is stripped as a trailing comment, the key has all + /// whitespace removed, and the value is trimmed. Lines without `=` are + /// ignored, and a final line without a trailing newline still counts. + pub fn parse_kw_config(content: &str) -> HashMap { + let mut entries = HashMap::new(); + for line in content.lines() { + if line.starts_with('#') || line.is_empty() { + continue; + } + let uncommented = match line.rfind('#') { + Some(index) => &line[..index], + None => line, + }; + let Some((key, value)) = uncommented.split_once('=') else { + continue; + }; + let key: String = key.chars().filter(|c| !c.is_whitespace()).collect(); + entries.insert(key, value.trim().to_string()); + } + entries + } + + /// Mirrors kw's `is_kernel_root`: the same files and directories kw checks + /// (also the set `get_maintainer.pl` relies on). `MAINTAINERS` is checked + /// with `exists` because kw uses `-e` on it and `-f` on the other files. + pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { + const FILES: [&str; 5] = ["COPYING", "CREDITS", "Kbuild", "Makefile", "README"]; + const DIRS: [&str; 10] = [ + "Documentation", + "arch", + "include", + "drivers", + "fs", + "init", + "ipc", + "kernel", + "lib", + "scripts", + ]; + + FILES.iter().all(|file| fs.is_file(&path.join(file))) + && fs.exists(&path.join("MAINTAINERS")) + && DIRS.iter().all(|dir| fs.is_dir(&path.join(dir))) + } + + /// Probes whether `tree_path` is a kernel tree ready for kw operations. + /// `output_dir` is the resolved kw-env `O=` path when an env is active: kw + /// refuses to activate an env while an in-tree `.config` exists + /// (`kw_env.sh::validate_env_before_switch`), so with an env active the + /// `.config` lives only at the env's `O=` dir. + pub fn probe_tree( + fs: &dyn FileSystemTrait, + tree_path: &Path, + output_dir: Option<&Path>, + ) -> TreeReadiness { + if !fs.is_dir(tree_path) { + return TreeReadiness::Missing; + } + if !Self::is_kernel_root(fs, tree_path) { + return TreeReadiness::NotAKernelRoot; + } + if !fs.is_dir(&tree_path.join(".kw")) { + return TreeReadiness::MissingKwDir; + } + if output_dir.is_some() + && (fs.is_file(&tree_path.join(".config")) + || fs.is_dir(&tree_path.join("include").join("config"))) + { + return TreeReadiness::InTreeBuildArtifacts; + } + let build_root = output_dir.unwrap_or(tree_path); + if !fs.is_file(&build_root.join(".config")) { + return TreeReadiness::MissingKernelConfig; + } + TreeReadiness::Ready { + arch: Self::read_build_arch(fs, tree_path), + } + } + + /// Reads the literal `arch=` value from `/.kw/build.config`, the same + /// value kw's image discovery globs under `arch//boot/`. Returns + /// `None` when the file or the key is absent or empty — kw's + /// `${build_config[arch]:-...}` expansion treats empty as unset — meaning + /// the caller falls back to globbing `arch/*/boot/`, a deliberate + /// divergence from kw's merged-config fallback (see + /// [`ReadinessService::find_newest_kernel_image`]). + pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { + let content = fs + .read_to_string(&tree_path.join(".kw").join("build.config")) + .ok()?; + let arch = Self::parse_kw_config(&content).remove("arch")?; + if arch.is_empty() { + None + } else { + Some(arch) + } + } + + /// Resolves kw's active build output dir (`O=`) for `tree_path`, mirroring + /// kw's env handling: the env name is the content of + /// `/.kw/env.current` (trailing newlines stripped, like bash's + /// `$(< ...)`), and the output dir is + /// `{XDG_CACHE_HOME | ~/.cache}/kw/envs//`. + /// The tree path is encoded like kw 0.10's `get_encoded_pwd` — standard + /// base64 with padding, no wrapping — after trimming trailing slashes, + /// since kw encodes `$PWD` after changing into the tree. The encoded path + /// may contain `/` (standard alphabet), producing nested directories; kw + /// has the same behavior. + /// + /// kw's launcher recomputes the cache dir unconditionally, so a + /// user-exported `KW_CACHE_DIR` is intentionally ignored here too. The + /// `KWORKFLOW` rename knob is not honored: it exists for kw development. + /// + /// Returns `Ok(None)` when no env is active. The resolved dir is not + /// required to exist: kw/make create it on first build. An unreadable + /// `env.current` or an unresolvable cache base (neither `XDG_CACHE_HOME` + /// nor `HOME` set) is an error, since the env state is then unknown — + /// kw's "active but unresolvable" case. + pub fn resolve_output_dir( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> Result, KwReadinessError> { + let env_file = tree_path.join(".kw").join("env.current"); + if !fs.is_file(&env_file) { + return Ok(None); + } + let env_name = fs + .read_to_string(&env_file)? + .trim_end_matches('\n') + .to_string(); + // An empty env.current names no env; kw's $(<) read yields the same + // empty string and kw then behaves as if no env were active. + if env_name.is_empty() { + return Ok(None); + } + + let cache_base = match env.var("XDG_CACHE_HOME") { + // bash's `:-` (and the XDG spec) treat a set-but-empty value as + // unset; env::var would happily return it as Ok(""). + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.cache", env.var("HOME")?), + }; + let trimmed = tree_path.to_string_lossy(); + let normalized = match trimmed.trim_end_matches('/') { + "" => "/", + path => path, + }; + let encoded = BASE64.encode(normalized); + Ok(Some( + Path::new(&cache_base) + .join("kw") + .join("envs") + .join(encoded) + .join(env_name), + )) + } + + /// Finds the newest kernel image under `/arch/`. Candidate + /// basenames must end with `Image` (the `-name '*Image'` in kw's + /// `get_kernel_binary_name` is case-sensitive, so `Image.gz` and `image` + /// are excluded) and the most recently modified one wins, with ties broken + /// by descending path (kw's `sort -r | head -1`). + /// + /// With `arch`, only `arch//boot/` is probed — exactly kw's behavior. + /// Without `arch` this is a **deliberate divergence**, not a mirror: kw + /// falls back to the merged kw-config `arch` (packaged default `x86_64`, a + /// directory that does not exist in kernel trees, so `kw deploy` then fails + /// with exit 125), and patch-hub does not read kw's global config layers. + /// Globbing every `arch/*/boot/` gives a more useful readiness signal than + /// probing a directory that is never there — at the cost of possibly + /// reporting an image kw would not find. A green image probe with `arch=` + /// unset is therefore not a guarantee kw deploy will locate one; setting + /// `arch=` in `.kw/build.config` makes the two agree. + /// + /// Second deliberate deviation: kw's `find` recurses into boot/ + /// subdirectories, while this scans only the top level. Kernel images for + /// every arch kw supports are produced directly in boot/ (subdirs like + /// compressed/ or dts/ never hold `*Image` files), and find does not + /// descend into symlinked dirs either, so the behaviors agree on real + /// trees. + pub fn find_newest_kernel_image( + fs: &dyn FileSystemTrait, + build_root: &Path, + arch: Option<&str>, + ) -> Option { + match arch { + Some(arch) => { + Self::find_newest_image_in(fs, &build_root.join("arch").join(arch).join("boot")) + } + None => fs + .read_dir(&build_root.join("arch")) + .ok()? + .into_iter() + .filter(|entry| fs.is_dir(entry)) + .filter_map(|arch_dir| { + Self::find_newest_image_in(fs, &arch_dir.join("boot")) + .map(|image| (Self::read_image_mtime(fs, &image), image)) + }) + .max_by_key(|(mtime, _)| *mtime) + .map(|(_, image)| image), + } + } + + /// Reads the built kernel's release string from + /// `/include/config/kernel.release`, the file a kernel build + /// generates — cheaper than re-running `make kernelrelease`, and `None` + /// when the build never produced one (or produced an empty one). + pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option { + let release = fs + .read_to_string( + &build_root + .join("include") + .join("config") + .join("kernel.release"), + ) + .ok()?; + let release = release.trim(); + if release.is_empty() { + None + } else { + Some(release.to_string()) + } + } + + /// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but + /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess + /// at the home copy. A readable tree file that simply omits the key still + /// falls through, matching kw's merged-config lookup. + pub fn probe_boot_once( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> BootOnceState { + let local = tree_path.join(".kw").join("deploy.config"); + if fs.is_file(&local) { + match Self::read_boot_once_from_file(fs, &local) { + Err(()) => return BootOnceState::Unknown, + Ok(Some(state)) => return state, + Ok(None) => {} + } + } + let Some(global) = Self::resolve_xdg_kw_config_file(env, "deploy.config") else { + return BootOnceState::Unknown; + }; + if !fs.is_file(&global) { + return BootOnceState::Unknown; + } + match Self::read_boot_once_from_file(fs, &global) { + Ok(Some(state)) => state, + Ok(None) | Err(()) => BootOnceState::Unknown, + } + } + + /// Deploy-alone readiness gate: a deploy without a preceding build is only + /// allowed when a successful build record exists for the tree and the + /// lookup branch, written against the same tree path and kw env, and a + /// kernel image is still discoverable. + /// + /// `record` is the lookup keyed by the deploy target branch. `latest` is + /// the newest record for the tree across branches. When the target has + /// no keyed record but another branch does, that is + /// [`DeployAloneRefusal::HeadMismatch`], not "no build recorded". + /// + /// This is only the record-matching half of the gate — it says nothing + /// about the tree's *current* state. [`ReadinessService::evaluate_readiness`] conjoins + /// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over + /// calling this directly. + pub fn check_deploy_alone( + record: Option<&KwBuildRecord>, + latest: Option<&KwBuildRecord>, + tree: &KernelTree, + head_branch: &str, + output_dir: Option<&Path>, + image: Option<&Path>, + ) -> Result<(), DeployAloneRefusal> { + let record = match record { + Some(record) => record, + None => { + if let Some(latest) = latest { + if latest.branch != head_branch { + return Err(DeployAloneRefusal::HeadMismatch { + recorded: latest.branch.clone(), + current: head_branch.to_string(), + }); + } + } + return Err(DeployAloneRefusal::NoBuildRecord); + } + }; + if !record.success { + return Err(DeployAloneRefusal::LastBuildFailed); + } + if record.branch != head_branch { + return Err(DeployAloneRefusal::HeadMismatch { + recorded: record.branch.clone(), + current: head_branch.to_string(), + }); + } + // Trailing slashes are normalized away: a config edit that only adds + // or drops one does not move the tree. + if record.tree_path.trim_end_matches('/') != tree.path().trim_end_matches('/') { + return Err(DeployAloneRefusal::TreePathDrift { + recorded: record.tree_path.clone(), + current: tree.path().to_string(), + }); + } + let current_output_dir = output_dir.map(|p| p.to_string_lossy().into_owned()); + if record.output_dir != current_output_dir { + return Err(DeployAloneRefusal::OutputDirMismatch); + } + if image.is_none() { + return Err(DeployAloneRefusal::ImageMissing); + } + Ok(()) + } + + /// Runs all readiness probes for `tree` and composes them into a + /// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — + /// resolving it (via git) is the caller's job, keeping these probes pure. + /// + /// `for_branch`, when set, is the branch deploy-alone should be judged + /// against (the branch typed on KwOps). `current_branch` still reports + /// the real HEAD so the UI can show both. + #[expect(clippy::too_many_arguments)] + pub fn evaluate_readiness( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + shell: &dyn ShellTrait, + history: &dyn KwHistoryStore, + kernel_tree_id: &str, + tree: &KernelTree, + head_branch: &str, + for_branch: Option<&str>, + ) -> Result { + let tree_path = Path::new(tree.path()); + let kw_binary = Self::probe_kw_binary(env, shell); + let output_dir = Self::resolve_output_dir(fs, env, tree_path)?; + let tree_status = Self::probe_tree(fs, tree_path, output_dir.as_deref()); + let arch = match &tree_status { + TreeReadiness::Ready { arch } => arch.clone(), + _ => None, + }; + let kernel_image = Self::find_newest_kernel_image( + fs, + output_dir.as_deref().unwrap_or(tree_path), + arch.as_deref(), + ); + let lookup_branch = match for_branch + .map(str::trim) + .filter(|branch| !branch.is_empty()) + { + Some(branch) => branch, + None => head_branch, + }; + let (build_record, latest_build) = history.build_records(kernel_tree_id, lookup_branch)?; + // The tree's current state is part of the verdict: a stale image and a + // matching record must not green-light a deploy on a tree that has + // since lost its .config, .kw/, or kernel-root files. + let deploy_alone = match &tree_status { + TreeReadiness::Ready { .. } => Self::check_deploy_alone( + build_record.as_ref(), + latest_build.as_ref(), + tree, + lookup_branch, + output_dir.as_deref(), + kernel_image.as_deref(), + ), + other => Err(DeployAloneRefusal::TreeNotReady(other.clone())), + }; + Ok(KwReadiness { + kw_binary, + tree: tree_status, + output_dir, + deploy_alone, + current_branch: { + let trimmed = head_branch.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }, + deploy_remote: remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path), + boot_once: Self::probe_boot_once(fs, env, tree_path), + }) + } } impl ReadinessService { @@ -79,6 +489,53 @@ impl ReadinessService { .collect(); Some((major, minor.parse().ok()?)) } + + /// Newest `*Image` file directly inside `boot_dir`, if any. + fn find_newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { + fs.read_dir(boot_dir) + .ok()? + .into_iter() + .filter(|entry| { + entry + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with("Image")) + && fs.is_file(entry) + }) + .map(|entry| (Self::read_image_mtime(fs, &entry), entry)) + .max_by_key(|(mtime, _)| *mtime) + .map(|(_, entry)| entry) + } + + fn read_image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { + fs.metadata(path) + .and_then(|meta| meta.modified().map_err(FileSystemError::from)) + .unwrap_or(SystemTime::UNIX_EPOCH) + } + + fn read_boot_once_from_file( + fs: &dyn FileSystemTrait, + path: &Path, + ) -> Result, ()> { + let content = fs.read_to_string(path).map_err(|_| ())?; + Ok(Self::parse_kw_config(&content) + .remove("boot_into_new_kernel_once") + .map(|value| match value.as_str() { + "no" => BootOnceState::Off, + "yes" => BootOnceState::On, + _ => BootOnceState::Unknown, + })) + } + + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty + /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG + /// spec. + fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { + let config_home = match env.var("XDG_CONFIG_HOME") { + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.config", env.var("HOME").ok()?), + }; + Some(Path::new(&config_home).join("kw").join(filename)) + } } #[cfg(test)] diff --git a/src/kw/readiness/unix.rs b/src/kw/readiness/unix.rs deleted file mode 100644 index 5e92a6e..0000000 --- a/src/kw/readiness/unix.rs +++ /dev/null @@ -1,465 +0,0 @@ -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; - -use crate::infrastructure::{ - env::EnvTrait, - file_system::{FileSystemError, FileSystemTrait}, - shell::ShellTrait, -}; -use crate::{ - config::KernelTree, - kw::{history::KwHistoryStore, models::history::KwBuildRecord, remote}, -}; - -use crate::kw::{ - models::readiness::{ - BootOnceState, DeployAloneRefusal, KwReadiness, KwReadinessError, TreeReadiness, - }, - readiness::ReadinessService, -}; - -impl ReadinessService { - /// Parses kw's `key=value` config format (`.kw/build.config`, - /// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: - /// blank lines and lines starting with `#` are skipped, everything from the - /// last `#` on is stripped as a trailing comment, the key has all - /// whitespace removed, and the value is trimmed. Lines without `=` are - /// ignored, and a final line without a trailing newline still counts. - pub fn parse_kw_config(content: &str) -> HashMap { - let mut entries = HashMap::new(); - for line in content.lines() { - if line.starts_with('#') || line.is_empty() { - continue; - } - let uncommented = match line.rfind('#') { - Some(index) => &line[..index], - None => line, - }; - let Some((key, value)) = uncommented.split_once('=') else { - continue; - }; - let key: String = key.chars().filter(|c| !c.is_whitespace()).collect(); - entries.insert(key, value.trim().to_string()); - } - entries - } - - /// Mirrors kw's `is_kernel_root`: the same files and directories kw checks - /// (also the set `get_maintainer.pl` relies on). `MAINTAINERS` is checked - /// with `exists` because kw uses `-e` on it and `-f` on the other files. - pub fn is_kernel_root(fs: &dyn FileSystemTrait, path: &Path) -> bool { - const FILES: [&str; 5] = ["COPYING", "CREDITS", "Kbuild", "Makefile", "README"]; - const DIRS: [&str; 10] = [ - "Documentation", - "arch", - "include", - "drivers", - "fs", - "init", - "ipc", - "kernel", - "lib", - "scripts", - ]; - - FILES.iter().all(|file| fs.is_file(&path.join(file))) - && fs.exists(&path.join("MAINTAINERS")) - && DIRS.iter().all(|dir| fs.is_dir(&path.join(dir))) - } - - /// Probes whether `tree_path` is a kernel tree ready for kw operations. - /// `output_dir` is the resolved kw-env `O=` path when an env is active: kw - /// refuses to activate an env while an in-tree `.config` exists - /// (`kw_env.sh::validate_env_before_switch`), so with an env active the - /// `.config` lives only at the env's `O=` dir. - pub fn probe_tree( - fs: &dyn FileSystemTrait, - tree_path: &Path, - output_dir: Option<&Path>, - ) -> TreeReadiness { - if !fs.is_dir(tree_path) { - return TreeReadiness::Missing; - } - if !Self::is_kernel_root(fs, tree_path) { - return TreeReadiness::NotAKernelRoot; - } - if !fs.is_dir(&tree_path.join(".kw")) { - return TreeReadiness::MissingKwDir; - } - if output_dir.is_some() - && (fs.is_file(&tree_path.join(".config")) - || fs.is_dir(&tree_path.join("include").join("config"))) - { - return TreeReadiness::InTreeBuildArtifacts; - } - let build_root = output_dir.unwrap_or(tree_path); - if !fs.is_file(&build_root.join(".config")) { - return TreeReadiness::MissingKernelConfig; - } - TreeReadiness::Ready { - arch: Self::read_build_arch(fs, tree_path), - } - } - - /// Reads the literal `arch=` value from `/.kw/build.config`, the same - /// value kw's image discovery globs under `arch//boot/`. Returns - /// `None` when the file or the key is absent or empty — kw's - /// `${build_config[arch]:-...}` expansion treats empty as unset — meaning - /// the caller falls back to globbing `arch/*/boot/`, a deliberate - /// divergence from kw's merged-config fallback (see - /// [`ReadinessService::find_newest_kernel_image`]). - pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { - let content = fs - .read_to_string(&tree_path.join(".kw").join("build.config")) - .ok()?; - let arch = Self::parse_kw_config(&content).remove("arch")?; - if arch.is_empty() { - None - } else { - Some(arch) - } - } - - /// Resolves kw's active build output dir (`O=`) for `tree_path`, mirroring - /// kw's env handling: the env name is the content of - /// `/.kw/env.current` (trailing newlines stripped, like bash's - /// `$(< ...)`), and the output dir is - /// `{XDG_CACHE_HOME | ~/.cache}/kw/envs//`. - /// The tree path is encoded like kw 0.10's `get_encoded_pwd` — standard - /// base64 with padding, no wrapping — after trimming trailing slashes, - /// since kw encodes `$PWD` after changing into the tree. The encoded path - /// may contain `/` (standard alphabet), producing nested directories; kw - /// has the same behavior. - /// - /// kw's launcher recomputes the cache dir unconditionally, so a - /// user-exported `KW_CACHE_DIR` is intentionally ignored here too. The - /// `KWORKFLOW` rename knob is not honored: it exists for kw development. - /// - /// Returns `Ok(None)` when no env is active. The resolved dir is not - /// required to exist: kw/make create it on first build. An unreadable - /// `env.current` or an unresolvable cache base (neither `XDG_CACHE_HOME` - /// nor `HOME` set) is an error, since the env state is then unknown — - /// kw's "active but unresolvable" case. - pub fn resolve_output_dir( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, - ) -> Result, KwReadinessError> { - let env_file = tree_path.join(".kw").join("env.current"); - if !fs.is_file(&env_file) { - return Ok(None); - } - let env_name = fs - .read_to_string(&env_file)? - .trim_end_matches('\n') - .to_string(); - // An empty env.current names no env; kw's $(<) read yields the same - // empty string and kw then behaves as if no env were active. - if env_name.is_empty() { - return Ok(None); - } - - let cache_base = match env.var("XDG_CACHE_HOME") { - // bash's `:-` (and the XDG spec) treat a set-but-empty value as - // unset; env::var would happily return it as Ok(""). - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.cache", env.var("HOME")?), - }; - let trimmed = tree_path.to_string_lossy(); - let normalized = match trimmed.trim_end_matches('/') { - "" => "/", - path => path, - }; - let encoded = BASE64.encode(normalized); - Ok(Some( - Path::new(&cache_base) - .join("kw") - .join("envs") - .join(encoded) - .join(env_name), - )) - } - - /// Finds the newest kernel image under `/arch/`. Candidate - /// basenames must end with `Image` (the `-name '*Image'` in kw's - /// `get_kernel_binary_name` is case-sensitive, so `Image.gz` and `image` - /// are excluded) and the most recently modified one wins, with ties broken - /// by descending path (kw's `sort -r | head -1`). - /// - /// With `arch`, only `arch//boot/` is probed — exactly kw's behavior. - /// Without `arch` this is a **deliberate divergence**, not a mirror: kw - /// falls back to the merged kw-config `arch` (packaged default `x86_64`, a - /// directory that does not exist in kernel trees, so `kw deploy` then fails - /// with exit 125), and patch-hub does not read kw's global config layers. - /// Globbing every `arch/*/boot/` gives a more useful readiness signal than - /// probing a directory that is never there — at the cost of possibly - /// reporting an image kw would not find. A green image probe with `arch=` - /// unset is therefore not a guarantee kw deploy will locate one; setting - /// `arch=` in `.kw/build.config` makes the two agree. - /// - /// Second deliberate deviation: kw's `find` recurses into boot/ - /// subdirectories, while this scans only the top level. Kernel images for - /// every arch kw supports are produced directly in boot/ (subdirs like - /// compressed/ or dts/ never hold `*Image` files), and find does not - /// descend into symlinked dirs either, so the behaviors agree on real - /// trees. - pub fn find_newest_kernel_image( - fs: &dyn FileSystemTrait, - build_root: &Path, - arch: Option<&str>, - ) -> Option { - match arch { - Some(arch) => { - Self::find_newest_image_in(fs, &build_root.join("arch").join(arch).join("boot")) - } - None => fs - .read_dir(&build_root.join("arch")) - .ok()? - .into_iter() - .filter(|entry| fs.is_dir(entry)) - .filter_map(|arch_dir| { - Self::find_newest_image_in(fs, &arch_dir.join("boot")) - .map(|image| (Self::read_image_mtime(fs, &image), image)) - }) - .max_by_key(|(mtime, _)| *mtime) - .map(|(_, image)| image), - } - } - - /// Reads the built kernel's release string from - /// `/include/config/kernel.release`, the file a kernel build - /// generates — cheaper than re-running `make kernelrelease`, and `None` - /// when the build never produced one (or produced an empty one). - pub fn read_kernelrelease(fs: &dyn FileSystemTrait, build_root: &Path) -> Option { - let release = fs - .read_to_string( - &build_root - .join("include") - .join("config") - .join("kernel.release"), - ) - .ok()?; - let release = release.trim(); - if release.is_empty() { - None - } else { - Some(release.to_string()) - } - } - - /// Reads `boot_into_new_kernel_once` from `/.kw/deploy.config`, then - /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/deploy.config`. A present but - /// unreadable tree file is [`BootOnceState::Unknown`] rather than a guess - /// at the home copy. A readable tree file that simply omits the key still - /// falls through, matching kw's merged-config lookup. - pub fn probe_boot_once( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, - ) -> BootOnceState { - let local = tree_path.join(".kw").join("deploy.config"); - if fs.is_file(&local) { - match Self::read_boot_once_from_file(fs, &local) { - Err(()) => return BootOnceState::Unknown, - Ok(Some(state)) => return state, - Ok(None) => {} - } - } - let Some(global) = Self::resolve_xdg_kw_config_file(env, "deploy.config") else { - return BootOnceState::Unknown; - }; - if !fs.is_file(&global) { - return BootOnceState::Unknown; - } - match Self::read_boot_once_from_file(fs, &global) { - Ok(Some(state)) => state, - Ok(None) | Err(()) => BootOnceState::Unknown, - } - } - - /// Deploy-alone readiness gate: a deploy without a preceding build is only - /// allowed when a successful build record exists for the tree and the - /// lookup branch, written against the same tree path and kw env, and a - /// kernel image is still discoverable. - /// - /// `record` is the lookup keyed by the deploy target branch. `latest` is - /// the newest record for the tree across branches. When the target has - /// no keyed record but another branch does, that is - /// [`DeployAloneRefusal::HeadMismatch`], not "no build recorded". - /// - /// This is only the record-matching half of the gate — it says nothing - /// about the tree's *current* state. [`ReadinessService::evaluate_readiness`] conjoins - /// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over - /// calling this directly. - pub fn check_deploy_alone( - record: Option<&KwBuildRecord>, - latest: Option<&KwBuildRecord>, - tree: &KernelTree, - head_branch: &str, - output_dir: Option<&Path>, - image: Option<&Path>, - ) -> Result<(), DeployAloneRefusal> { - let record = match record { - Some(record) => record, - None => { - if let Some(latest) = latest { - if latest.branch != head_branch { - return Err(DeployAloneRefusal::HeadMismatch { - recorded: latest.branch.clone(), - current: head_branch.to_string(), - }); - } - } - return Err(DeployAloneRefusal::NoBuildRecord); - } - }; - if !record.success { - return Err(DeployAloneRefusal::LastBuildFailed); - } - if record.branch != head_branch { - return Err(DeployAloneRefusal::HeadMismatch { - recorded: record.branch.clone(), - current: head_branch.to_string(), - }); - } - // Trailing slashes are normalized away: a config edit that only adds - // or drops one does not move the tree. - if record.tree_path.trim_end_matches('/') != tree.path().trim_end_matches('/') { - return Err(DeployAloneRefusal::TreePathDrift { - recorded: record.tree_path.clone(), - current: tree.path().to_string(), - }); - } - let current_output_dir = output_dir.map(|p| p.to_string_lossy().into_owned()); - if record.output_dir != current_output_dir { - return Err(DeployAloneRefusal::OutputDirMismatch); - } - if image.is_none() { - return Err(DeployAloneRefusal::ImageMissing); - } - Ok(()) - } - - /// Runs all readiness probes for `tree` and composes them into a - /// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — - /// resolving it (via git) is the caller's job, keeping these probes pure. - /// - /// `for_branch`, when set, is the branch deploy-alone should be judged - /// against (the branch typed on KwOps). `current_branch` still reports - /// the real HEAD so the UI can show both. - #[expect(clippy::too_many_arguments)] - pub fn evaluate_readiness( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - shell: &dyn ShellTrait, - history: &dyn KwHistoryStore, - kernel_tree_id: &str, - tree: &KernelTree, - head_branch: &str, - for_branch: Option<&str>, - ) -> Result { - let tree_path = Path::new(tree.path()); - let kw_binary = Self::probe_kw_binary(env, shell); - let output_dir = Self::resolve_output_dir(fs, env, tree_path)?; - let tree_status = Self::probe_tree(fs, tree_path, output_dir.as_deref()); - let arch = match &tree_status { - TreeReadiness::Ready { arch } => arch.clone(), - _ => None, - }; - let kernel_image = Self::find_newest_kernel_image( - fs, - output_dir.as_deref().unwrap_or(tree_path), - arch.as_deref(), - ); - let lookup_branch = match for_branch - .map(str::trim) - .filter(|branch| !branch.is_empty()) - { - Some(branch) => branch, - None => head_branch, - }; - let (build_record, latest_build) = history.build_records(kernel_tree_id, lookup_branch)?; - // The tree's current state is part of the verdict: a stale image and a - // matching record must not green-light a deploy on a tree that has - // since lost its .config, .kw/, or kernel-root files. - let deploy_alone = match &tree_status { - TreeReadiness::Ready { .. } => Self::check_deploy_alone( - build_record.as_ref(), - latest_build.as_ref(), - tree, - lookup_branch, - output_dir.as_deref(), - kernel_image.as_deref(), - ), - other => Err(DeployAloneRefusal::TreeNotReady(other.clone())), - }; - Ok(KwReadiness { - kw_binary, - tree: tree_status, - output_dir, - deploy_alone, - current_branch: { - let trimmed = head_branch.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }, - deploy_remote: remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path), - boot_once: Self::probe_boot_once(fs, env, tree_path), - }) - } -} - -impl ReadinessService { - /// Newest `*Image` file directly inside `boot_dir`, if any. - fn find_newest_image_in(fs: &dyn FileSystemTrait, boot_dir: &Path) -> Option { - fs.read_dir(boot_dir) - .ok()? - .into_iter() - .filter(|entry| { - entry - .file_name() - .is_some_and(|name| name.to_string_lossy().ends_with("Image")) - && fs.is_file(entry) - }) - .map(|entry| (Self::read_image_mtime(fs, &entry), entry)) - .max_by_key(|(mtime, _)| *mtime) - .map(|(_, entry)| entry) - } - - fn read_image_mtime(fs: &dyn FileSystemTrait, path: &Path) -> SystemTime { - fs.metadata(path) - .and_then(|meta| meta.modified().map_err(FileSystemError::from)) - .unwrap_or(SystemTime::UNIX_EPOCH) - } - - fn read_boot_once_from_file( - fs: &dyn FileSystemTrait, - path: &Path, - ) -> Result, ()> { - let content = fs.read_to_string(path).map_err(|_| ())?; - Ok(Self::parse_kw_config(&content) - .remove("boot_into_new_kernel_once") - .map(|value| match value.as_str() { - "no" => BootOnceState::Off, - "yes" => BootOnceState::On, - _ => BootOnceState::Unknown, - })) - } - - /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/`. A set-but-empty - /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG - /// spec. - fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), - }; - Some(Path::new(&config_home).join("kw").join(filename)) - } -} diff --git a/src/kw/remote/mod.rs b/src/kw/remote.rs similarity index 65% rename from src/kw/remote/mod.rs rename to src/kw/remote.rs index 1c2566c..36ad75f 100644 --- a/src/kw/remote/mod.rs +++ b/src/kw/remote.rs @@ -12,12 +12,189 @@ //! to the home copy would silently deploy to a different machine than the //! tree is configured for. -use crate::kw::models::remote::KwRemote; +use std::path::{Path, PathBuf}; -#[cfg(unix)] -mod unix; -#[cfg(unix)] -pub use unix::RemoteConfigService; +use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; + +use crate::kw::models::remote::{KwRemote, ParsedRemoteConfig, RemoteRefusal}; + +struct HostBuilder { + name: String, + hostname: Option, + port: Option, + user: Option, +} + +impl HostBuilder { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + hostname: None, + port: None, + user: None, + } + } + + fn finish(self) -> Option { + let hostname = self.hostname.filter(|value| !value.is_empty())?; + let port = match self.port.as_deref() { + None | Some("") => 22, + Some(value) => value.parse().ok().filter(|port| *port != 0)?, + }; + let user = self.user.filter(|value| !value.is_empty()); + Some(KwRemote { + name: self.name, + hostname, + port, + user, + }) + } +} + +pub struct RemoteConfigService; + +impl RemoteConfigService { + /// Parses kw's ssh-config-like `remote.config`. Blank lines and comments + /// are skipped; `#kw-default=` (anywhere) names the default Host, + /// last occurrence winning; `Host ` starts a stanza; `Hostname`, + /// `Port`, and `User` are read case-insensitively; `IdentityFile` and + /// other keys are tolerated and ignored. Port defaults to 22 when unset. + /// Duplicate Host names keep the last complete stanza. + pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { + let mut parsed = ParsedRemoteConfig::default(); + let mut current: Option = None; + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Some(name) = trimmed.strip_prefix("#kw-default=") { + let name = name.trim(); + if !name.is_empty() { + parsed.default = Some(name.to_string()); + } + continue; + } + if trimmed.starts_with('#') { + continue; + } + + let Some((keyword, rest)) = Self::split_keyword(trimmed) else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + if let Some(host) = current.take().and_then(HostBuilder::finish) { + Self::upsert_host(&mut parsed.hosts, host); + } + let name = rest.split_whitespace().next().unwrap_or(""); + if !name.is_empty() { + current = Some(HostBuilder::new(name)); + } + continue; + } + let Some(builder) = current.as_mut() else { + continue; + }; + if keyword.eq_ignore_ascii_case("hostname") { + if !rest.is_empty() { + builder.hostname = Some(rest.to_string()); + } + } else if keyword.eq_ignore_ascii_case("port") { + if !rest.is_empty() { + builder.port = Some(rest.to_string()); + } + } else if keyword.eq_ignore_ascii_case("user") && !rest.is_empty() { + builder.user = Some(rest.to_string()); + } + } + if let Some(host) = current.and_then(HostBuilder::finish) { + Self::upsert_host(&mut parsed.hosts, host); + } + parsed + } + + /// Picks the deploy remote from a parsed file: the `#kw-default=` Host + /// when set, otherwise the only Host. Multiple hosts with no default, or + /// a default that names no complete Host, are refusals. + pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result { + if let Some(name) = parsed.default.as_deref() { + return parsed + .hosts + .iter() + .find(|host| host.name == name) + .cloned() + .ok_or_else(|| RemoteRefusal::DefaultNotFound { + name: name.to_string(), + }); + } + match parsed.hosts.as_slice() { + [] => Err(RemoteRefusal::NoRemotesConfigured), + [only] => Ok(only.clone()), + hosts => Err(RemoteRefusal::NoDefault { count: hosts.len() }), + } + } + + /// Resolves the remote kw deploy will be pointed at. See the [`remote`](crate::kw::remote) + /// module docs for the file lookup order. + pub fn resolve_deploy_remote( + fs: &dyn FileSystemTrait, + env: &dyn EnvTrait, + tree_path: &Path, + ) -> Result { + let local = tree_path.join(".kw").join("remote.config"); + if fs.is_file(&local) { + return Self::read_remote_from_file(fs, &local); + } + let Some(global) = Self::resolve_xdg_remote_config(env) else { + return Err(RemoteRefusal::NoRemotesConfigured); + }; + if fs.is_file(&global) { + return Self::read_remote_from_file(fs, &global); + } + Err(RemoteRefusal::NoRemotesConfigured) + } +} + +impl RemoteConfigService { + fn read_remote_from_file( + fs: &dyn FileSystemTrait, + path: &Path, + ) -> Result { + let content = fs + .read_to_string(path) + .map_err(|_| RemoteRefusal::NoRemotesConfigured)?; + Self::select_deploy_remote(&Self::parse_remote_config(&content)) + } + + /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/remote.config`. A set-but-empty + /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG + /// spec — otherwise the path would be relative to cwd. + fn resolve_xdg_remote_config(env: &dyn EnvTrait) -> Option { + let config_home = match env.var("XDG_CONFIG_HOME") { + Ok(xdg) if !xdg.is_empty() => xdg, + _ => format!("{}/.config", env.var("HOME").ok()?), + }; + Some(Path::new(&config_home).join("kw").join("remote.config")) + } + + fn split_keyword(line: &str) -> Option<(&str, &str)> { + let keyword_end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); + let keyword = &line[..keyword_end]; + if keyword.is_empty() { + return None; + } + Some((keyword, line[keyword_end..].trim())) + } + + fn upsert_host(hosts: &mut Vec, host: KwRemote) { + if let Some(existing) = hosts.iter_mut().find(|entry| entry.name == host.name) { + *existing = host; + } else { + hosts.push(host); + } + } +} impl KwRemote { /// The `--remote` token kw's deploy parser accepts: diff --git a/src/kw/remote/unix.rs b/src/kw/remote/unix.rs deleted file mode 100644 index 3469295..0000000 --- a/src/kw/remote/unix.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::path::{Path, PathBuf}; - -use crate::infrastructure::{env::EnvTrait, file_system::FileSystemTrait}; - -use crate::kw::models::remote::{KwRemote, ParsedRemoteConfig, RemoteRefusal}; - -struct HostBuilder { - name: String, - hostname: Option, - port: Option, - user: Option, -} - -impl HostBuilder { - fn new(name: &str) -> Self { - Self { - name: name.to_string(), - hostname: None, - port: None, - user: None, - } - } - - fn finish(self) -> Option { - let hostname = self.hostname.filter(|value| !value.is_empty())?; - let port = match self.port.as_deref() { - None | Some("") => 22, - Some(value) => value.parse().ok().filter(|port| *port != 0)?, - }; - let user = self.user.filter(|value| !value.is_empty()); - Some(KwRemote { - name: self.name, - hostname, - port, - user, - }) - } -} - -pub struct RemoteConfigService; - -impl RemoteConfigService { - /// Parses kw's ssh-config-like `remote.config`. Blank lines and comments - /// are skipped; `#kw-default=` (anywhere) names the default Host, - /// last occurrence winning; `Host ` starts a stanza; `Hostname`, - /// `Port`, and `User` are read case-insensitively; `IdentityFile` and - /// other keys are tolerated and ignored. Port defaults to 22 when unset. - /// Duplicate Host names keep the last complete stanza. - pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { - let mut parsed = ParsedRemoteConfig::default(); - let mut current: Option = None; - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if let Some(name) = trimmed.strip_prefix("#kw-default=") { - let name = name.trim(); - if !name.is_empty() { - parsed.default = Some(name.to_string()); - } - continue; - } - if trimmed.starts_with('#') { - continue; - } - - let Some((keyword, rest)) = Self::split_keyword(trimmed) else { - continue; - }; - if keyword.eq_ignore_ascii_case("host") { - if let Some(host) = current.take().and_then(HostBuilder::finish) { - Self::upsert_host(&mut parsed.hosts, host); - } - let name = rest.split_whitespace().next().unwrap_or(""); - if !name.is_empty() { - current = Some(HostBuilder::new(name)); - } - continue; - } - let Some(builder) = current.as_mut() else { - continue; - }; - if keyword.eq_ignore_ascii_case("hostname") { - if !rest.is_empty() { - builder.hostname = Some(rest.to_string()); - } - } else if keyword.eq_ignore_ascii_case("port") { - if !rest.is_empty() { - builder.port = Some(rest.to_string()); - } - } else if keyword.eq_ignore_ascii_case("user") && !rest.is_empty() { - builder.user = Some(rest.to_string()); - } - } - if let Some(host) = current.and_then(HostBuilder::finish) { - Self::upsert_host(&mut parsed.hosts, host); - } - parsed - } - - /// Picks the deploy remote from a parsed file: the `#kw-default=` Host - /// when set, otherwise the only Host. Multiple hosts with no default, or - /// a default that names no complete Host, are refusals. - pub fn select_deploy_remote(parsed: &ParsedRemoteConfig) -> Result { - if let Some(name) = parsed.default.as_deref() { - return parsed - .hosts - .iter() - .find(|host| host.name == name) - .cloned() - .ok_or_else(|| RemoteRefusal::DefaultNotFound { - name: name.to_string(), - }); - } - match parsed.hosts.as_slice() { - [] => Err(RemoteRefusal::NoRemotesConfigured), - [only] => Ok(only.clone()), - hosts => Err(RemoteRefusal::NoDefault { count: hosts.len() }), - } - } - - /// Resolves the remote kw deploy will be pointed at. See the [`remote`](super) - /// module docs for the file lookup order. - pub fn resolve_deploy_remote( - fs: &dyn FileSystemTrait, - env: &dyn EnvTrait, - tree_path: &Path, - ) -> Result { - let local = tree_path.join(".kw").join("remote.config"); - if fs.is_file(&local) { - return Self::read_remote_from_file(fs, &local); - } - let Some(global) = Self::resolve_xdg_remote_config(env) else { - return Err(RemoteRefusal::NoRemotesConfigured); - }; - if fs.is_file(&global) { - return Self::read_remote_from_file(fs, &global); - } - Err(RemoteRefusal::NoRemotesConfigured) - } -} - -impl RemoteConfigService { - fn read_remote_from_file( - fs: &dyn FileSystemTrait, - path: &Path, - ) -> Result { - let content = fs - .read_to_string(path) - .map_err(|_| RemoteRefusal::NoRemotesConfigured)?; - Self::select_deploy_remote(&Self::parse_remote_config(&content)) - } - - /// `${XDG_CONFIG_HOME:-$HOME/.config}/kw/remote.config`. A set-but-empty - /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG - /// spec — otherwise the path would be relative to cwd. - fn resolve_xdg_remote_config(env: &dyn EnvTrait) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), - }; - Some(Path::new(&config_home).join("kw").join("remote.config")) - } - - fn split_keyword(line: &str) -> Option<(&str, &str)> { - let keyword_end = line.find(|c: char| c.is_whitespace()).unwrap_or(line.len()); - let keyword = &line[..keyword_end]; - if keyword.is_empty() { - return None; - } - Some((keyword, line[keyword_end..].trim())) - } - - fn upsert_host(hosts: &mut Vec, host: KwRemote) { - if let Some(existing) = hosts.iter_mut().find(|entry| entry.name == host.name) { - *existing = host; - } else { - hosts.push(host); - } - } -} diff --git a/src/kw/status.rs b/src/kw/status.rs index 53323ff..9f2c155 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -5,7 +5,6 @@ use std::path::PathBuf; -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KwJobKind { Build, @@ -14,14 +13,12 @@ pub enum KwJobKind { } /// Running phase of a job. `BuildThenDeploy` jobs pass through both. -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KwPhase { Building, Deploying, } -#[cfg_attr(not(unix), expect(dead_code))] #[derive(Debug, Clone, PartialEq, Eq)] pub enum KwJobStatus { Idle, @@ -67,7 +64,6 @@ pub struct KwStatusSnapshot { } impl KwStatusSnapshot { - #[cfg(unix)] pub fn idle() -> Self { Self { job: KwJobStatus::Idle, diff --git a/src/main.rs b/src/main.rs index 1fced14..ae0d007 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,7 +41,6 @@ use terminal::{actor::TerminalActor, session::CrosstermTerminalSession}; use tokio::sync::mpsc; use tracing::{event, Level}; use ui::actor::UiActor; -#[cfg(unix)] use {infrastructure::process::OsProcess, kw::actor::KwActor, std::path::Path}; #[tokio::main] @@ -104,10 +103,6 @@ async fn main() -> Result<()> { config.data_dir().to_string(), )); - // The kw actor is unix-only because ProcessTrait (process-group kill) - // is; everywhere else there is no handle and App falls back to writing - // apply history directly. - #[cfg(unix)] let kw_handle = Some(KwActor::spawn( kw_history.clone(), Arc::new(OsProcess), @@ -116,8 +111,6 @@ async fn main() -> Result<()> { Arc::new(OsEnv), Path::new(config.cache_dir()).join("kw_logs"), )); - #[cfg(not(unix))] - let kw_handle = None; let render = RenderActor::spawn(Box::new(ShellRenderService::new(shell_arc.clone()))); From c826081e8ef1772cb0d4b526659351f089de9bdb Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 09:00:37 -0300 Subject: [PATCH 39/61] ci: stop allowing too-many-arguments in clippy Signed-off-by: lorenzoberts --- .github/workflows/format_and_lint.yml | 2 +- .pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/format_and_lint.yml b/.github/workflows/format_and_lint.yml index 5edbd93..a7e7d25 100644 --- a/.github/workflows/format_and_lint.yml +++ b/.github/workflows/format_and_lint.yml @@ -58,4 +58,4 @@ jobs: - name: Check clippy errors shell: bash run: | - cargo clippy --all-features --all-targets --tests -- --allow=clippy::too-many-arguments --deny=warnings --deny=clippy::map_unwrap_or --deny=unconditional_recursion + cargo clippy --all-features --all-targets --tests -- --deny=warnings --deny=clippy::map_unwrap_or --deny=unconditional_recursion diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c22f868..36da6e1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: entry: cargo clippy args: [ "--all-features", "--all-targets", "--tests", - "--", "--allow=clippy::too-many-arguments", "--deny=warnings", + "--", "--deny=warnings", "--deny=clippy::map_unwrap_or", "--deny=unconditional_recursion" ] types: [rust] From a864f0c90d0f4db91ddc8af2ad25744179b4ec73 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 09:02:50 -0300 Subject: [PATCH 40/61] test(lore): cover mbox files that are not valid UTF-8 Signed-off-by: lorenzoberts --- src/lore/infrastructure/patchset_parser.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index 5ad5040..fcb36be 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -228,6 +228,24 @@ mod tests { ); } + #[test] + fn split_patchset_returns_error_on_invalid_utf8() { + let path = std::env::temp_dir().join(format!( + "patch-hub-mbox-invalid-utf8-{}.mbx", + std::process::id() + )); + fs::write(&path, b"Subject: not utf-8\n\xff\n").expect("write invalid mbox"); + let path_str = path.to_str().expect("temp path is utf-8"); + let err = parser() + .split_patchset(path_str) + .expect_err("invalid UTF-8 must return an error"); + let _ = fs::remove_file(&path); + assert!( + err.starts_with(&format!("{path_str}: ")), + "expected \"{{path}}: {{err}}\", got {err}" + ); + } + #[test] fn split_patchset_without_cover_letter() { let patches = parser() From 8cae99771cf7d2f3bc9053cdd0f61b161a45b592 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 09:24:14 -0300 Subject: [PATCH 41/61] fix(infrastructure): keep handled errors from tearing down the terminal eyre calls the installed hook from capture_handler on every Report construction (Report::from_std, from_adhoc, from_display, from_msg, and from_boxed), which is `?` into eyre::Result, eyre!, and bail!. That includes errors the app handles and keeps running after. A failed fetch does bail! in src/app/screens/latest.rs:60, and src/app/flows/open_patchset.rs:42 turns that Err into an error popup. Restoring the terminal there dropped the alternate screen and raw mode while the TUI was still running. The eyre hook now only forwards to color_eyre (src/infrastructure/errors.rs:73). The panic hook still restores first (src/infrastructure/errors.rs:66-69). main returns color_eyre::Result (src/main.rs:47). #[tokio::main] propagates that Result, and std::process::Termination prints it with Debug after main returns. The eyre hook does not print; it only captures color_eyre's handler when the Report is built. The report would therefore be printed into raw/alternate mode unless something restores first. Nothing on the fatal Err path did. AppActor::run (src/app/actor.rs:79) returns I/O errors with `?` and does not shut the terminal down. AppHandle::run_until_done (src/app/handle.rs:20-22) only joins that task. main calls terminal_handle.shutdown (src/main.rs:190-193) only after run_until_done returns Ok. That reaches CrosstermTerminalSession::shutdown (src/terminal/session.rs:118-125), which calls restore(). teardown_user_io (src/infrastructure/terminal.rs:59-63) turns raw mode back on for an editor; it is not process teardown. Errors before init (src/main.rs:58, src/main.rs:61-62, src/main.rs:73, src/main.rs:77) never enter raw mode, so they must not restore: `LeaveAlternateScreen` would emit a stray escape sequence. init (src/infrastructure/terminal.rs:28-41) rolls its own partial setup back: LeaveAlternateScreen if raw mode fails, restore() if Terminal::new fails. After init succeeds, bootstrap (src/main.rs:132-135), App::new (src/main.rs:137-150), subscribe_app (src/main.rs:153-156), run_until_done (src/main.rs:177-178), and input shutdown (src/main.rs:179-182) can return Err before terminal shutdown. main calls restore_once() (src/main.rs:200-202) before returning that Err, so the runtime prints the report on a normal terminal. TerminalRestoreGuard stays. A panic in a spawned task runs the panic hook and then comes back as a join error from main, so both would restore. The guard makes the second call a no-op. restore_guard_runs_the_operation_once still covers that. A test that the eyre hook does not restore is not feasible without a real terminal or a new injection seam: install_hooks sets a process-global hook, and restore() talks to crossterm. Signed-off-by: lorenzoberts --- src/infrastructure/errors.rs | 36 +++-- src/infrastructure/terminal.rs | 23 +++- src/main.rs | 231 +++++++++++++++++---------------- 3 files changed, 165 insertions(+), 125 deletions(-) diff --git a/src/infrastructure/errors.rs b/src/infrastructure/errors.rs index 45b9b7f..257e32b 100644 --- a/src/infrastructure/errors.rs +++ b/src/infrastructure/errors.rs @@ -12,10 +12,23 @@ use color_eyre::{ use super::terminal::restore; -struct TerminalRestoreGuard { +pub(crate) struct TerminalRestoreGuard { started: AtomicBool, } +static TERMINAL_RESTORE: TerminalRestoreGuard = TerminalRestoreGuard::new(); + +impl TerminalRestoreGuard { + /// Restores the terminal at most once in this process. + /// + /// The panic hook and `main`'s fatal-error return both call this. A panic in a + /// spawned task runs the hook and then comes back to `main` as a join error, + /// so those two calls would otherwise restore twice. + pub(crate) fn restore_once() { + TERMINAL_RESTORE.call(restore); + } +} + impl TerminalRestoreGuard { const fn new() -> Self { Self { @@ -33,28 +46,31 @@ impl TerminalRestoreGuard { } } -static TERMINAL_RESTORE: TerminalRestoreGuard = TerminalRestoreGuard::new(); - -/// This replaces the standard color_eyre panic and error hooks with hooks that -/// restore the terminal before printing the panic or error. +/// Installs the process panic and eyre hooks. /// -/// Normal application shutdown restores the terminal through -/// [`crate::terminal::handle::TerminalHandle::shutdown`]. These hooks keep a -/// direct [`super::terminal::restore`] fallback for panics and fatal errors. +/// The panic hook restores the terminal, then color_eyre prints the panic. +/// The eyre hook only forwards to color_eyre. eyre runs that hook for every +/// report construction, including errors the application handles and keeps +/// running after, so it must not restore the terminal. +/// +/// `main` returns [`color_eyre::eyre::Result`], and the runtime prints a fatal +/// report after `main` returns. [`TerminalRestoreGuard::restore_once`] runs on +/// that path first. +/// Normal shutdown restores through +/// [`crate::terminal::handle::TerminalHandle::shutdown`]. pub fn install_hooks() -> Result<()> { let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); // convert from a color_eyre PanicHook to a standard panic hook let panic_hook = panic_hook.into_panic_hook(); panic::set_hook(Box::new(move |panic_info| { - TERMINAL_RESTORE.call(restore); + TerminalRestoreGuard::restore_once(); panic_hook(panic_info); })); // convert from a color_eyre EyreHook to a eyre ErrorHook let eyre_hook = eyre_hook.into_eyre_hook(); set_hook(Box::new(move |error: &(dyn Error + 'static)| { - TERMINAL_RESTORE.call(restore); eyre_hook(error) }))?; diff --git a/src/infrastructure/terminal.rs b/src/infrastructure/terminal.rs index 7c78a0b..852728f 100644 --- a/src/infrastructure/terminal.rs +++ b/src/infrastructure/terminal.rs @@ -2,8 +2,8 @@ //! emergency restore hooks. //! //! Normal runtime startup uses [`init`] from `main`, session operations go -//! through [`crate::terminal::session::CrosstermTerminalSession`], and fatal -//! error hooks call [`restore`] directly. +//! through [`crate::terminal::session::CrosstermTerminalSession`], and the +//! panic hook plus `main`'s fatal-error path call [`restore`] directly. use ratatui::{ crossterm::{ @@ -21,11 +21,24 @@ use std::io::{self, stdout, Stdout}; /// A type alias for the terminal type used in this application pub type Tui = Terminal>; -/// Initialize the terminal +/// Initialize the terminal. +/// +/// A failure after the alternate screen is entered leaves that mode before +/// returning, so the caller's error can be printed on the normal terminal. pub fn init() -> io::Result { execute!(stdout(), EnterAlternateScreen)?; - enable_raw_mode()?; - Terminal::new(CrosstermBackend::new(stdout())) + if let Err(error) = enable_raw_mode() { + let _ = execute!(stdout(), LeaveAlternateScreen); + return Err(error); + } + + match Terminal::new(CrosstermBackend::new(stdout())) { + Ok(terminal) => Ok(terminal), + Err(error) => { + let _ = restore(); + Err(error) + } + } } /// Restore the terminal to its original state diff --git a/src/main.rs b/src/main.rs index ae0d007..699447b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,118 +78,129 @@ async fn main() -> Result<()> { let config_handle = ConfigActor::spawn(config_state, config_repo); let terminal_handle = TerminalActor::spawn(Box::new(CrosstermTerminalSession::new(init()?))); - let ui_handle = UiActor::spawn(); - - // Build shared infrastructure dependencies for LoreService - let net = Arc::new(UreqNetClient::new()); - let fs_arc: Arc = Arc::new(OsFileSystem); - let shell_arc: Arc = Arc::new(OsShell); - - let gateway = Arc::new(HttpLoreGateway::new(net)); - let persistence = Arc::new(FileLorePersistence::new( - fs_arc.clone(), - config.mailing_lists_path().to_string(), - config.bookmarked_patchsets_path().to_string(), - config.reviewed_patchsets_path().to_string(), - )); - let fetcher = Arc::new(B4PatchsetFetcher::new( - shell_arc.clone(), - fs_arc.clone(), - config.patchsets_cache_dir().to_string(), - )); - let parser = Arc::new(MboxPatchsetParser::new(fs_arc.clone())); - let kw_history: Arc = Arc::new(FileKwHistoryStore::new( - fs_arc.clone(), - config.data_dir().to_string(), - )); - - let kw_handle = Some(KwActor::spawn( - kw_history.clone(), - Arc::new(OsProcess), - shell_arc.clone(), - fs_arc.clone(), - Arc::new(OsEnv), - Path::new(config.cache_dir()).join("kw_logs"), - )); - - let render = RenderActor::spawn(Box::new(ShellRenderService::new(shell_arc.clone()))); - - let lore_api = LoreApiActor::spawn(LoreService::new( - gateway.clone(), - gateway.clone(), - gateway.clone(), - persistence.clone() as Arc, - persistence.clone() as Arc, - fetcher.clone(), - parser.clone(), - fs_arc.clone(), - shell_arc.clone(), - CacheTtl::default(), - )); - let bootstrap = lore_api - .get_bootstrap_data() - .await - .map_err(|error| eyre!("failed to bootstrap Lore data: {error}"))?; - - let app = App::new( - config_handle - .get_snapshot() + // Later `?` returns skip terminal shutdown. Restore before the runtime + // prints that report, after `main` returns. + let result = async { + let ui_handle = UiActor::spawn(); + + // Build shared infrastructure dependencies for LoreService + let net = Arc::new(UreqNetClient::new()); + let fs_arc: Arc = Arc::new(OsFileSystem); + let shell_arc: Arc = Arc::new(OsShell); + + let gateway = Arc::new(HttpLoreGateway::new(net)); + let persistence = Arc::new(FileLorePersistence::new( + fs_arc.clone(), + config.mailing_lists_path().to_string(), + config.bookmarked_patchsets_path().to_string(), + config.reviewed_patchsets_path().to_string(), + )); + let fetcher = Arc::new(B4PatchsetFetcher::new( + shell_arc.clone(), + fs_arc.clone(), + config.patchsets_cache_dir().to_string(), + )); + let parser = Arc::new(MboxPatchsetParser::new(fs_arc.clone())); + let kw_history: Arc = Arc::new(FileKwHistoryStore::new( + fs_arc.clone(), + config.data_dir().to_string(), + )); + + let kw_handle = Some(KwActor::spawn( + kw_history.clone(), + Arc::new(OsProcess), + shell_arc.clone(), + fs_arc.clone(), + Arc::new(OsEnv), + Path::new(config.cache_dir()).join("kw_logs"), + )); + + let render = RenderActor::spawn(Box::new(ShellRenderService::new(shell_arc.clone()))); + + let lore_api = LoreApiActor::spawn(LoreService::new( + gateway.clone(), + gateway.clone(), + gateway.clone(), + persistence.clone() as Arc, + persistence.clone() as Arc, + fetcher.clone(), + parser.clone(), + fs_arc.clone(), + shell_arc.clone(), + CacheTtl::default(), + )); + let bootstrap = lore_api + .get_bootstrap_data() .await - .map_err(|error| eyre!("{error}"))?, - config_handle.clone(), - bootstrap, - fs_arc.clone(), - Box::new(OsShell), - lore_api.clone(), - render.clone(), - kw_history.clone(), - kw_handle.clone(), - )?; - let (app_input_tx, app_input_rx) = mpsc::channel::(64); - let input_handle = InputActor::spawn(terminal_handle.clone(), app.input_context()); - input_handle - .subscribe_app(app_input_tx) - .await - .map_err(|e| eyre!("{e}"))?; - let input_shutdown_handle = input_handle.clone(); - - // Shutdown ordering: - // 1. AppActor — exits when the user quits (input channel closes) - // 2. InputActor — no further terminal input is needed once App is gone - // 3. KwActor — no further kw requests once App is gone; kills any - // running job's process group before stopping - // 4. ConfigActor — no further configuration requests once App is gone - // 5. LoreApiActor — no further requests once App is gone - // 6. RenderActor — no further requests once App is gone - // 7. UiActor — no further scene builds once App is gone - // 8. TerminalActor — restores the terminal last so the screen stays usable - // during the steps above - AppActor::spawn( - app, - terminal_handle.clone(), - ui_handle.clone(), - input_handle, - app_input_rx, - ) - .run_until_done() - .await?; - input_shutdown_handle - .shutdown() - .await - .map_err(|e| eyre!("{e}"))?; - if let Some(kw_handle) = kw_handle { - kw_handle.shutdown().await; + .map_err(|error| eyre!("failed to bootstrap Lore data: {error}"))?; + + let app = App::new( + config_handle + .get_snapshot() + .await + .map_err(|error| eyre!("{error}"))?, + config_handle.clone(), + bootstrap, + fs_arc.clone(), + Box::new(OsShell), + lore_api.clone(), + render.clone(), + kw_history.clone(), + kw_handle.clone(), + )?; + let (app_input_tx, app_input_rx) = mpsc::channel::(64); + let input_handle = InputActor::spawn(terminal_handle.clone(), app.input_context()); + input_handle + .subscribe_app(app_input_tx) + .await + .map_err(|e| eyre!("{e}"))?; + let input_shutdown_handle = input_handle.clone(); + + // Shutdown ordering: + // 1. AppActor — exits when the user quits (input channel closes) + // 2. InputActor — no further terminal input is needed once App is gone + // 3. KwActor — no further kw requests once App is gone; kills any + // running job's process group before stopping + // 4. ConfigActor — no further configuration requests once App is gone + // 5. LoreApiActor — no further requests once App is gone + // 6. RenderActor — no further requests once App is gone + // 7. UiActor — no further scene builds once App is gone + // 8. TerminalActor — restores the terminal last so the screen stays usable + // during the steps above + AppActor::spawn( + app, + terminal_handle.clone(), + ui_handle.clone(), + input_handle, + app_input_rx, + ) + .run_until_done() + .await?; + input_shutdown_handle + .shutdown() + .await + .map_err(|e| eyre!("{e}"))?; + if let Some(kw_handle) = kw_handle { + kw_handle.shutdown().await; + } + config_handle.shutdown().await; + lore_api.shutdown().await; + render.shutdown().await; + ui_handle.shutdown().await; + terminal_handle + .shutdown() + .await + .map_err(|error| eyre!("{error}"))?; + + event!(Level::INFO, "patch-hub finished"); + + Ok(()) + } + .await; + if let Err(error) = result { + infrastructure::errors::TerminalRestoreGuard::restore_once(); + return Err(error); } - config_handle.shutdown().await; - lore_api.shutdown().await; - render.shutdown().await; - ui_handle.shutdown().await; - terminal_handle - .shutdown() - .await - .map_err(|error| eyre!("{error}"))?; - - event!(Level::INFO, "patch-hub finished"); Ok(()) } From cdb1172a281037081cdc95ef0993947b8c11ef70 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 11:53:26 -0300 Subject: [PATCH 42/61] refactor(config): derive ConfigSnapshot from ConfigState via From Snapshots and edit-config drafts are projections of their source state, so callers build them with From instead of dedicated converter methods. Signed-off-by: lorenzoberts --- src/app/actions/apply.rs | 58 ++++++++++--------- src/app/actor.rs | 6 +- src/app/dependencies.rs | 11 ++-- src/app/flows/edit_config.rs | 6 +- .../integration_tests/helpers/app_harness.rs | 4 +- src/app/integration_tests/kw_ops.rs | 23 ++++---- src/app/integration_tests/kw_status.rs | 23 ++++---- src/app/integration_tests/patchset_actions.rs | 56 +++++++++--------- src/app/mod.rs | 4 +- src/app/screens/edit_config.rs | 46 ++++++++------- src/app/view_model.rs | 11 ++-- src/cli.rs | 6 +- src/config/actor.rs | 4 +- src/config/state.rs | 48 ++++++++------- src/config/tests.rs | 5 +- src/main.rs | 4 +- 16 files changed, 160 insertions(+), 155 deletions(-) diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index 0ff36c4..3d7d405 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -320,40 +320,42 @@ mod tests { // `stay_on_applied_branch` is deliberately absent so the tests below // exercise the serde default (true) that existing config files inherit. fn config() -> ConfigSnapshot { - serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-" - })) - .expect("test config should deserialize") - .to_snapshot() + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-" + })) + .expect("test config should deserialize"), + ) } fn config_stay_disabled() -> ConfigSnapshot { - serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-", - "stay_on_applied_branch": false - })) - .expect("test config should deserialize") - .to_snapshot() + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-", + "stay_on_applied_branch": false + })) + .expect("test config should deserialize"), + ) } fn config_without_target() -> ConfigSnapshot { - ConfigState::default().to_snapshot() + ConfigSnapshot::from(&ConfigState::default()) } fn request() -> ApplyPatchsetRequest { diff --git a/src/app/actor.rs b/src/app/actor.rs index aeb168e..e76db9c 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -372,7 +372,7 @@ mod tests { state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, AppServices, }, - config::{ConfigHandle, ConfigState}, + config::{ConfigHandle, ConfigSnapshot, ConfigState}, infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, kw::history::MockKwHistoryStore, @@ -432,7 +432,7 @@ mod tests { reviewed_patchsets: HashMap::new(), }, config_state: ConfigUiState { edit_config: None }, - config: ConfigState::default().to_snapshot(), + config: ConfigSnapshot::from(&ConfigState::default()), popup: None, kw: Default::default(), }, @@ -538,7 +538,7 @@ mod tests { let ui_handle = UiActor::spawn(); let app = App::new( - ConfigState::default().to_snapshot(), + ConfigSnapshot::from(&ConfigState::default()), dummy_config_handle(), bootstrap, Arc::new(MockFileSystemTrait::new()), diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 879fc6f..484329a 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -125,7 +125,7 @@ mod tests { let err = DependencyService::check_external_deps( &env, &shell, - &ConfigState::default().to_snapshot(), + &ConfigSnapshot::from(&ConfigState::default()), ) .unwrap_err(); @@ -142,7 +142,7 @@ mod tests { let result = DependencyService::check_external_deps( &env, &shell, - &ConfigState::default().to_snapshot(), + &ConfigSnapshot::from(&ConfigState::default()), ); assert!(result.is_ok()); @@ -161,7 +161,8 @@ mod tests { .withf(|name| name == "bat") .returning(|_| false); - let result = DependencyService::check_external_deps(&env, &shell, &state.to_snapshot()); + let result = + DependencyService::check_external_deps(&env, &shell, &ConfigSnapshot::from(&state)); assert!(result.is_ok()); } @@ -178,7 +179,7 @@ mod tests { let result = DependencyService::check_external_deps( &env, &shell, - &ConfigState::default().to_snapshot(), + &ConfigSnapshot::from(&ConfigState::default()), ); assert!(result.is_ok()); @@ -200,7 +201,7 @@ mod tests { let result = DependencyService::check_external_deps( &env, &shell, - &ConfigState::default().to_snapshot(), + &ConfigSnapshot::from(&ConfigState::default()), ); assert!(result.is_ok()); diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 4473aa9..21649b5 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -117,7 +117,9 @@ mod tests { state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, App, AppServices, }, - config::{ConfigActor, ConfigHandle, ConfigService, DEFAULT_CONFIG_PATH_SUFFIX}, + config::{ + ConfigActor, ConfigHandle, ConfigService, ConfigSnapshot, DEFAULT_CONFIG_PATH_SUFFIX, + }, infrastructure::{ env::MockEnvTrait, file_system::MockFileSystemTrait, file_system::OsFileSystem, shell::MockShellTrait, @@ -180,7 +182,7 @@ mod tests { ); } state.target_kernel_tree = target.map(str::to_string); - let snapshot = state.to_snapshot(); + let snapshot = ConfigSnapshot::from(&state); let config = ConfigActor::spawn(state, repo); let dummy_list = MailingList::new("test-list", "Test list"); diff --git a/src/app/integration_tests/helpers/app_harness.rs b/src/app/integration_tests/helpers/app_harness.rs index df632bb..e168f6e 100644 --- a/src/app/integration_tests/helpers/app_harness.rs +++ b/src/app/integration_tests/helpers/app_harness.rs @@ -4,7 +4,7 @@ use tokio::sync::mpsc; use crate::{ app::App, - config::{ConfigHandle, ConfigState}, + config::{ConfigHandle, ConfigSnapshot, ConfigState}, infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, kw::history::MockKwHistoryStore, lore::application::{handle::LoreApiHandle, models::cache::BootstrapLoreData}, @@ -48,7 +48,7 @@ pub(crate) fn app_with_bootstrap_and_handles( render: RenderHandle, ) -> App { App::new( - ConfigState::default().to_snapshot(), + ConfigSnapshot::from(&ConfigState::default()), dummy_config_handle(), bootstrap, Arc::new(MockFileSystemTrait::new()), diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index 4874ad0..b421496 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -909,17 +909,18 @@ mod unix { } fn apply_config() -> ConfigSnapshot { - serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": "/kernel", - "branch": "main" - } - }, - "target_kernel_tree": "linux" - })) - .expect("test config should deserialize") - .to_snapshot() + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": "/kernel", + "branch": "main" + } + }, + "target_kernel_tree": "linux" + })) + .expect("test config should deserialize"), + ) } async fn shutdown_kw(app: &App) { diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index f964416..13c3281 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -372,17 +372,18 @@ fn start_request() -> StartRequest { } fn apply_config() -> ConfigSnapshot { - serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": "main" - } - }, - "target_kernel_tree": "linux" - })) - .expect("test config should deserialize") - .to_snapshot() + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": "main" + } + }, + "target_kernel_tree": "linux" + })) + .expect("test config should deserialize"), + ) } fn kw_actor_shell() -> MockShellTrait { diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index 01f40aa..5f102b9 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -608,36 +608,38 @@ fn history_store_allowing_writes() -> MockKwHistoryStore { } fn apply_config() -> ConfigSnapshot { - serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-" - })) - .expect("test config should deserialize") - .to_snapshot() + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-" + })) + .expect("test config should deserialize"), + ) } fn apply_config_stay_disabled() -> ConfigSnapshot { - serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-", - "stay_on_applied_branch": false - })) - .expect("test config should deserialize") - .to_snapshot() + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-", + "stay_on_applied_branch": false + })) + .expect("test config should deserialize"), + ) } fn clean_fs() -> MockFileSystemTrait { diff --git a/src/app/mod.rs b/src/app/mod.rs index e304f21..7e5f272 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -43,7 +43,7 @@ use crate::{ reviewed_reply::ReviewedReplyRequest, PatchsetActionService, }, - config::{ConfigHandle, ConfigSnapshot}, + config::{ConfigHandle, ConfigSnapshot, ConfigUpdateDraft}, infrastructure::{ file_system::FileSystemTrait, monitoring::logging::garbage_collector::collect_garbage, shell::ShellTrait, @@ -339,7 +339,7 @@ impl App { pub async fn consolidate_edit_config(&mut self) -> Result<()> { if let Some(edit_config) = &self.state.config_state.edit_config { debug!("validating and applying config update"); - let draft = edit_config.to_update_draft(); + let draft = ConfigUpdateDraft::from(edit_config); let snapshot = self .services .config diff --git a/src/app/screens/edit_config.rs b/src/app/screens/edit_config.rs index d4ac283..9d7d55d 100644 --- a/src/app/screens/edit_config.rs +++ b/src/app/screens/edit_config.rs @@ -197,40 +197,42 @@ impl EditConfigState { .insert(editable_config, mem::take(&mut self.curr_edit)); } } +} - /// Raw form values for config validation. - pub fn to_update_draft(&self) -> ConfigUpdateDraft { - ConfigUpdateDraft { - page_size: self.config_buffer.get(&EditableConfig::PageSize).cloned(), - cache_dir: self.config_buffer.get(&EditableConfig::CacheDir).cloned(), - data_dir: self.config_buffer.get(&EditableConfig::DataDir).cloned(), - git_send_email_option: self +/// Raw form values for config validation. +impl From<&EditConfigState> for ConfigUpdateDraft { + fn from(state: &EditConfigState) -> Self { + Self { + page_size: state.config_buffer.get(&EditableConfig::PageSize).cloned(), + cache_dir: state.config_buffer.get(&EditableConfig::CacheDir).cloned(), + data_dir: state.config_buffer.get(&EditableConfig::DataDir).cloned(), + git_send_email_option: state .config_buffer .get(&EditableConfig::GitSendEmailOpt) .cloned(), - git_am_option: self.config_buffer.get(&EditableConfig::GitAmOpt).cloned(), - patch_renderer: self + git_am_option: state.config_buffer.get(&EditableConfig::GitAmOpt).cloned(), + patch_renderer: state .config_buffer .get(&EditableConfig::PatchRenderer) .cloned(), - cover_renderer: self + cover_renderer: state .config_buffer .get(&EditableConfig::CoverRenderer) .cloned(), - max_log_age: self.config_buffer.get(&EditableConfig::MaxLogAge).cloned(), - stay_on_applied_branch: self + max_log_age: state.config_buffer.get(&EditableConfig::MaxLogAge).cloned(), + stay_on_applied_branch: state .config_buffer .get(&EditableConfig::StayOnAppliedBranch) .cloned(), - kw_reboot_after_deploy: self + kw_reboot_after_deploy: state .config_buffer .get(&EditableConfig::KwRebootAfterDeploy) .cloned(), - kw_deploy_force: self + kw_deploy_force: state .config_buffer .get(&EditableConfig::KwDeployForce) .cloned(), - target_kernel_tree: self + target_kernel_tree: state .config_buffer .get(&EditableConfig::TargetKernelTree) .cloned(), @@ -325,7 +327,7 @@ mod tests { ); } state.target_kernel_tree = target.map(str::to_string); - state.to_snapshot() + ConfigSnapshot::from(&state) } fn tree_row(edit: &mut EditConfigState) { @@ -336,9 +338,9 @@ mod tests { #[test] fn draft_includes_deploy_knobs_with_compiled_in_defaults() { - let snapshot = ConfigState::default().to_snapshot(); + let snapshot = ConfigSnapshot::from(&ConfigState::default()); let edit = EditConfigState::new(&snapshot); - let draft = edit.to_update_draft(); + let draft = ConfigUpdateDraft::from(&edit); assert_eq!(Some("false".to_string()), draft.kw_reboot_after_deploy); assert_eq!(Some("true".to_string()), draft.kw_deploy_force); @@ -432,7 +434,7 @@ mod tests { edit.toggle_editing(); assert_eq!( Some("zebra".to_string()), - edit.to_update_draft().target_kernel_tree + ConfigUpdateDraft::from(&edit).target_kernel_tree ); tree_row(&mut edit); @@ -442,7 +444,7 @@ mod tests { edit.toggle_editing(); assert_eq!( Some(String::new()), - edit.to_update_draft().target_kernel_tree + ConfigUpdateDraft::from(&edit).target_kernel_tree ); assert_eq!( Some(( @@ -457,7 +459,7 @@ mod tests { fn dangling_target_is_seeded_as_unset() { let snapshot = snapshot_with_trees(&["zebra"], Some("linux")); let edit = EditConfigState::new(&snapshot); - let draft = edit.to_update_draft(); + let draft = ConfigUpdateDraft::from(&edit); assert_eq!(Some(String::new()), draft.target_kernel_tree); assert_eq!( @@ -475,7 +477,7 @@ mod tests { let edit = EditConfigState::new(&snapshot); assert_eq!( Some("linux".to_string()), - edit.to_update_draft().target_kernel_tree + ConfigUpdateDraft::from(&edit).target_kernel_tree ); } } diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 66351c2..b523c97 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -618,7 +618,7 @@ mod tests { AppState, ConfigUiState, KwUiState, LoreUiState, NavigationState, UserLoreState, }, }, - config::ConfigState, + config::{ConfigSnapshot, ConfigState}, kw::status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, lore::domain::mailing_list::MailingList, }; @@ -649,7 +649,7 @@ mod tests { reviewed_patchsets: HashMap::new(), }, config_state: ConfigUiState { edit_config: None }, - config: ConfigState::default().to_snapshot(), + config: ConfigSnapshot::from(&ConfigState::default()), popup: None, kw: KwUiState { status, ops: None }, } @@ -704,7 +704,7 @@ mod tests { })) .unwrap(), ); - state.config = config.to_snapshot(); + state.config = ConfigSnapshot::from(&config); let mut edit = crate::app::screens::edit_config::EditConfigState::new(&state.config); while edit.highlighted() != 11 { edit.highlight_next(); @@ -964,12 +964,11 @@ mod tests { fn deploy_command_follows_reboot_and_force_config() { let mut state = app_state_with_kw(None); state.navigation.current_screen = CurrentScreen::KwOps; - state.config = ConfigState { + state.config = ConfigSnapshot::from(&ConfigState { kw_reboot_after_deploy: true, kw_deploy_force: false, ..Default::default() - } - .to_snapshot(); + }); let mut ops = sample_kw_ops(Some("feature")); ops.readiness.deploy_remote = Ok(sample_remote()); state.kw.ops = Some(ops); diff --git a/src/cli.rs b/src/cli.rs index 7ff96b2..9e3ed77 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -35,14 +35,14 @@ impl Cli { #[cfg(test)] mod tests { use super::*; - use crate::config::ConfigState; + use crate::config::{ConfigSnapshot, ConfigState}; #[test] fn resolve_continues_when_no_early_cli_action_is_requested() { let cli = Cli { show_configs: false, }; - let config = ConfigState::default().to_snapshot(); + let config = ConfigSnapshot::from(&ConfigState::default()); let result = cli.resolve(&config); @@ -52,7 +52,7 @@ mod tests { #[test] fn resolve_finishes_after_printing_configs() { let cli = Cli { show_configs: true }; - let config = ConfigState::default().to_snapshot(); + let config = ConfigSnapshot::from(&ConfigState::default()); let result = cli.resolve(&config); diff --git a/src/config/actor.rs b/src/config/actor.rs index 00591de..c0b1f3a 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -76,7 +76,7 @@ where message_name, REPLY_DROPPED_LOG, reply, - self.state.to_snapshot(), + ConfigSnapshot::from(&self.state), ); ControlFlow::Continue(()) } @@ -104,7 +104,7 @@ where self.state.normalize_derived_paths(); ConfigService::ensure_directories(&self.state, self.repo.fs())?; self.repo.save(&self.state)?; - Ok(self.state.to_snapshot()) + Ok(ConfigSnapshot::from(&self.state)) } } diff --git a/src/config/state.rs b/src/config/state.rs index 6e04482..9282961 100644 --- a/src/config/state.rs +++ b/src/config/state.rs @@ -104,10 +104,6 @@ impl ConfigState { } } - pub fn to_snapshot(&self) -> ConfigSnapshot { - ConfigSnapshot::from_state(self) - } - /// Recomputes derived path fields from `cache_dir` and `data_dir`. pub fn normalize_derived_paths(&mut self) { let cache_dir = self.cache_dir.clone(); @@ -227,31 +223,33 @@ pub struct ConfigSnapshot { kw_deploy_force: bool, } -impl ConfigSnapshot { - pub(crate) fn from_state(s: &ConfigState) -> Self { +impl From<&ConfigState> for ConfigSnapshot { + fn from(state: &ConfigState) -> Self { Self { - page_size: s.page_size, - patchsets_cache_dir: s.patchsets_cache_dir.clone(), - bookmarked_patchsets_path: s.bookmarked_patchsets_path.clone(), - mailing_lists_path: s.mailing_lists_path.clone(), - reviewed_patchsets_path: s.reviewed_patchsets_path.clone(), - logs_path: s.logs_path.clone(), - git_send_email_options: s.git_send_email_options.clone(), - cache_dir: s.cache_dir.clone(), - data_dir: s.data_dir.clone(), - patch_renderer: s.patch_renderer, - cover_renderer: s.cover_renderer, - max_log_age: s.max_log_age, - kernel_trees: s.kernel_trees.clone(), - target_kernel_tree: s.target_kernel_tree.clone(), - git_am_options: s.git_am_options.clone(), - git_am_branch_prefix: s.git_am_branch_prefix.clone(), - stay_on_applied_branch: s.stay_on_applied_branch, - kw_reboot_after_deploy: s.kw_reboot_after_deploy, - kw_deploy_force: s.kw_deploy_force, + page_size: state.page_size, + patchsets_cache_dir: state.patchsets_cache_dir.clone(), + bookmarked_patchsets_path: state.bookmarked_patchsets_path.clone(), + mailing_lists_path: state.mailing_lists_path.clone(), + reviewed_patchsets_path: state.reviewed_patchsets_path.clone(), + logs_path: state.logs_path.clone(), + git_send_email_options: state.git_send_email_options.clone(), + cache_dir: state.cache_dir.clone(), + data_dir: state.data_dir.clone(), + patch_renderer: state.patch_renderer, + cover_renderer: state.cover_renderer, + max_log_age: state.max_log_age, + kernel_trees: state.kernel_trees.clone(), + target_kernel_tree: state.target_kernel_tree.clone(), + git_am_options: state.git_am_options.clone(), + git_am_branch_prefix: state.git_am_branch_prefix.clone(), + stay_on_applied_branch: state.stay_on_applied_branch, + kw_reboot_after_deploy: state.kw_reboot_after_deploy, + kw_deploy_force: state.kw_deploy_force, } } +} +impl ConfigSnapshot { pub fn page_size(&self) -> usize { self.page_size } diff --git a/src/config/tests.rs b/src/config/tests.rs index 855c69c..6c8bf10 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -55,10 +55,7 @@ fn unique_test_dir(prefix: &str) -> PathBuf { } fn bootstrap_snapshot(env: &dyn EnvTrait) -> ConfigSnapshot { - ConfigService::bootstrap_parts(env, os_fs()) - .unwrap() - .0 - .to_snapshot() + ConfigSnapshot::from(&ConfigService::bootstrap_parts(env, os_fs()).unwrap().0) } /// Writable `HOME` and mock env: no `PATCH_HUB_CONFIG_PATH` (uses `HOME/.config/...`). diff --git a/src/main.rs b/src/main.rs index 699447b..d7321aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ use app::{actor::AppActor, dependencies::DependencyService, App}; use clap::Parser; use cli::Cli; use color_eyre::{eyre::eyre, Result}; -use config::{ConfigActor, ConfigService}; +use config::{ConfigActor, ConfigService, ConfigSnapshot}; use infrastructure::{ env::OsEnv, file_system::{FileSystemTrait, OsFileSystem}, @@ -60,7 +60,7 @@ async fn main() -> Result<()> { let env = OsEnv; let (config_state, config_repo) = ConfigService::bootstrap_parts(&env, OsFileSystem).map_err(|e| eyre!(e))?; - let config = config_state.to_snapshot(); + let config = ConfigSnapshot::from(&config_state); // with the config we can update log directory let _guards = multi_log_file_writer.update_log_writer_with_config( From fbd7d89d2eb9efd4b0ef4fa43389eb23ca134790 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 11:59:14 -0300 Subject: [PATCH 43/61] refactor(app): build view models via From<&AppState> Screen projections are derived from application state, so callers use From instead of project helpers. Signed-off-by: lorenzoberts --- src/app/mod.rs | 2 +- src/app/models/view_model.rs | 2 +- src/app/view_model.rs | 1111 ++++++++++++++++++---------------- 3 files changed, 582 insertions(+), 533 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 7e5f272..37ec13e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -361,7 +361,7 @@ impl App { /// This is the primary way for the orchestration layer to hand off /// presentation data to the UI actor without exposing raw `AppState`. pub fn present(&self) -> AppViewModel { - view_model::project_state(&self.state) + AppViewModel::from(&self.state) } } diff --git a/src/app/models/view_model.rs b/src/app/models/view_model.rs index 535d37f..86a74a0 100644 --- a/src/app/models/view_model.rs +++ b/src/app/models/view_model.rs @@ -184,7 +184,7 @@ pub enum ScreenViewModel { /// Owned, typed projection of [`AppState`] for one TUI frame. /// -/// Built by [`project_state`]; consumed by `UiCore::build_scene`. +/// Built from [`AppState`](crate::app::state::AppState); consumed by `UiCore::build_scene`. #[derive(Clone, Debug)] pub struct AppViewModel { pub screen: ScreenViewModel, diff --git a/src/app/view_model.rs b/src/app/view_model.rs index b523c97..887b31f 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -1,7 +1,7 @@ //! Owned, typed presentation projections. //! -//! [`AppViewModel`] is built from [`super::state::AppState`] by -//! [`project_state`], which is called via [`super::App::present`]. +//! [`AppViewModel`] is built from [`super::state::AppState`] and handed off +//! through [`super::App::present`]. //! //! These types sit at the *application* layer: they represent what `App` knows //! about the presentation before `UiCore` translates them into paint-ready @@ -30,580 +30,626 @@ use crate::app::models::view_model::{ TargetListStatus, }; -/// Projects `state` into an owned [`AppViewModel`]. +/// Builds the full view model the UI renders from the current app state. /// -/// Called via [`super::App::present`]. -pub fn project_state(state: &AppState) -> AppViewModel { - let screen = project_screen(state); - let popup = state.popup.as_ref().map(project_popup); - let kw_running = state - .kw - .status - .as_ref() - .and_then(KwStatusSnapshot::running_indicator); - AppViewModel { - screen, - popup, - kw_running, - } -} -fn project_screen(state: &AppState) -> ScreenViewModel { - match state.navigation.current_screen { - CurrentScreen::MailingListSelection => { - ScreenViewModel::MailingListSelection(project_mail_list(state)) - } - CurrentScreen::BookmarkedPatchsets => { - ScreenViewModel::Bookmarked(project_bookmarked(state)) +/// [`super::App::present`] performs this conversion. +impl From<&AppState> for AppViewModel { + fn from(state: &AppState) -> Self { + let screen = ScreenViewModel::from(state); + let popup = state.popup.as_ref().map(PopupViewModel::from); + let kw_running = state + .kw + .status + .as_ref() + .and_then(KwStatusSnapshot::running_indicator); + AppViewModel { + screen, + popup, + kw_running, } - CurrentScreen::LatestPatchsets => ScreenViewModel::Latest(project_latest(state)), - CurrentScreen::PatchsetDetails => ScreenViewModel::PatchsetDetails(project_details(state)), - CurrentScreen::EditConfig => ScreenViewModel::EditConfig(project_edit_config(state)), - CurrentScreen::KwOps => ScreenViewModel::KwOps(Box::new(project_kw_ops(state))), } } -fn project_mail_list(state: &AppState) -> MailingListSelectionViewModel { - let mls = &state.lore.mailing_list_selection; - - let target_list_status = if mls.target_list.is_empty() { - TargetListStatus::Empty - } else { - let mut status = TargetListStatus::NoMatch; - for list in &mls.mailing_lists { - if list.name().eq(&mls.target_list) { - status = TargetListStatus::ExactMatch; - break; - } else if list.name().starts_with(mls.target_list.as_str()) { - status = TargetListStatus::PrefixMatch; +impl From<&AppState> for ScreenViewModel { + fn from(state: &AppState) -> Self { + match state.navigation.current_screen { + CurrentScreen::MailingListSelection => { + ScreenViewModel::MailingListSelection(MailingListSelectionViewModel::from(state)) + } + CurrentScreen::BookmarkedPatchsets => { + ScreenViewModel::Bookmarked(BookmarkedViewModel::from(state)) } + CurrentScreen::LatestPatchsets => { + ScreenViewModel::Latest(LatestPatchsetsViewModel::from(state)) + } + CurrentScreen::PatchsetDetails => { + ScreenViewModel::PatchsetDetails(PatchsetDetailsViewModel::from(state)) + } + CurrentScreen::EditConfig => { + ScreenViewModel::EditConfig(EditConfigViewModel::from(state)) + } + CurrentScreen::KwOps => ScreenViewModel::KwOps(Box::new(KwOpsViewModel::from(state))), } - status - }; - - let entries = mls - .possible_mailing_lists - .iter() - .map(|l| MailingListEntry { - name: l.name().clone(), - description: l.description().clone(), - }) - .collect(); - - MailingListSelectionViewModel { - entries, - highlighted_index: mls.highlighted_list_index, - target_list: mls.target_list.clone(), - target_list_status, } } -fn project_bookmarked(state: &AppState) -> BookmarkedViewModel { - let bs = &state.user_state.bookmarked_patchsets; - let rows = bs - .bookmarked_patchsets - .iter() - .enumerate() - .map(|(i, p)| PatchSummaryRow { - title: p.title().clone(), - author_name: p.author().name.clone(), - version: p.version(), - total_in_series: p.total_in_series(), - absolute_index: i, - }) - .collect(); - BookmarkedViewModel { - rows, - selected_index: bs.patchset_index, +impl From<&AppState> for MailingListSelectionViewModel { + fn from(state: &AppState) -> Self { + let mls = &state.lore.mailing_list_selection; + + let target_list_status = if mls.target_list.is_empty() { + TargetListStatus::Empty + } else { + let mut status = TargetListStatus::NoMatch; + for list in &mls.mailing_lists { + if list.name().eq(&mls.target_list) { + status = TargetListStatus::ExactMatch; + break; + } else if list.name().starts_with(mls.target_list.as_str()) { + status = TargetListStatus::PrefixMatch; + } + } + status + }; + + let entries = mls + .possible_mailing_lists + .iter() + .map(|l| MailingListEntry { + name: l.name().clone(), + description: l.description().clone(), + }) + .collect(); + + MailingListSelectionViewModel { + entries, + highlighted_index: mls.highlighted_list_index, + target_list: mls.target_list.clone(), + target_list_status, + } } } -fn project_latest(state: &AppState) -> LatestPatchsetsViewModel { - let lps = state - .lore - .latest_patchsets - .as_ref() - .expect("LatestPatchsets must be initialised before projecting"); - - let page_number = lps.page_number(); - let base_index = (page_number - 1) * state.config.page_size(); - - let rows = lps - .get_current_patch_feed_page() - .unwrap_or_default() - .into_iter() - .enumerate() - .map(|(i, p)| PatchSummaryRow { - title: p.title().clone(), - author_name: p.author().name.clone(), - version: p.version(), - total_in_series: p.total_in_series(), - absolute_index: base_index + i, - }) - .collect(); - - LatestPatchsetsViewModel { - rows, - selected_index: lps.patchset_index(), - page_number, - target_list: lps.target_list().to_string(), +impl From<&AppState> for BookmarkedViewModel { + fn from(state: &AppState) -> Self { + let bs = &state.user_state.bookmarked_patchsets; + let rows = bs + .bookmarked_patchsets + .iter() + .enumerate() + .map(|(i, p)| PatchSummaryRow { + title: p.title().clone(), + author_name: p.author().name.clone(), + version: p.version(), + total_in_series: p.total_in_series(), + absolute_index: i, + }) + .collect(); + BookmarkedViewModel { + rows, + selected_index: bs.patchset_index, + } } } -fn project_details(state: &AppState) -> PatchsetDetailsViewModel { - let details = state - .lore - .details - .as_ref() - .expect("PatchsetDetails must be initialised before projecting"); - - let i = details.preview_index; - let message_id = &details.representative_patch.message_id().href; - - let preview_title = if matches!( - state.user_state.reviewed_patchsets.get(message_id), - Some(set) if set.contains(&i) - ) { - " Preview [REVIEWED-BY] ".to_string() - } else if *details.patches_to_reply.get(i).unwrap_or(&false) { - " Preview [REVIEWED-BY]* ".to_string() - } else { - " Preview ".to_string() - }; +impl From<&AppState> for LatestPatchsetsViewModel { + fn from(state: &AppState) -> Self { + let lps = state + .lore + .latest_patchsets + .as_ref() + .expect("LatestPatchsets must be initialised before projecting"); - let staged_to_reply = if *details - .patchset_actions - .get(&PatchsetAction::ReplyWithReviewedBy) - .unwrap_or(&false) - { - let number_offset = if details.has_cover_letter { 0 } else { 1 }; - let numbers: Vec = details - .patches_to_reply - .iter() + let page_number = lps.page_number(); + let base_index = (page_number - 1) * state.config.page_size(); + + let rows = lps + .get_current_patch_feed_page() + .unwrap_or_default() + .into_iter() .enumerate() - .filter_map(|(j, &val)| { - if val { - Some((j + number_offset).to_string()) - } else { - None - } + .map(|(i, p)| PatchSummaryRow { + title: p.title().clone(), + author_name: p.author().name.clone(), + version: p.version(), + total_in_series: p.total_in_series(), + absolute_index: base_index + i, }) .collect(); - if numbers.is_empty() { - None - } else { - Some(format!("({})", numbers.join(", "))) + + LatestPatchsetsViewModel { + rows, + selected_index: lps.patchset_index(), + page_number, + target_list: lps.target_list().to_string(), } - } else { - None - }; + } +} - PatchsetDetailsViewModel { - patch_title: details.representative_patch.title().clone(), - author_name: details.representative_patch.author().name.clone(), - version: details.representative_patch.version(), - patch_count: details.representative_patch.total_in_series(), - last_updated: details.representative_patch.updated().clone(), - tag_trailer_counts: TagTrailerCounts { - reviewed_by: details.reviewed_by[i].len(), - tested_by: details.tested_by[i].len(), - acked_by: details.acked_by[i].len(), - }, - staged_to_reply, - preview_entries: details - .patches_preview - .iter() - .map(|s| s.as_str().into_text().unwrap_or_default()) - .collect(), - preview_index: i, - preview_scroll_offset: details.preview_scroll_offset, - preview_pan: details.preview_pan, - preview_fullscreen: details.preview_fullscreen, - preview_title, - is_bookmarked: *details - .patchset_actions - .get(&PatchsetAction::Bookmark) - .unwrap_or(&false), - is_apply_staged: *details +impl From<&AppState> for PatchsetDetailsViewModel { + fn from(state: &AppState) -> Self { + let details = state + .lore + .details + .as_ref() + .expect("PatchsetDetails must be initialised before projecting"); + + let i = details.preview_index; + let message_id = &details.representative_patch.message_id().href; + + let preview_title = if matches!( + state.user_state.reviewed_patchsets.get(message_id), + Some(set) if set.contains(&i) + ) { + " Preview [REVIEWED-BY] ".to_string() + } else if *details.patches_to_reply.get(i).unwrap_or(&false) { + " Preview [REVIEWED-BY]* ".to_string() + } else { + " Preview ".to_string() + }; + + let staged_to_reply = if *details .patchset_actions - .get(&PatchsetAction::Apply) - .unwrap_or(&false), - is_current_patch_reply_staged: *details.patches_to_reply.get(i).unwrap_or(&false), + .get(&PatchsetAction::ReplyWithReviewedBy) + .unwrap_or(&false) + { + let number_offset = if details.has_cover_letter { 0 } else { 1 }; + let numbers: Vec = details + .patches_to_reply + .iter() + .enumerate() + .filter_map(|(j, &val)| { + if val { + Some((j + number_offset).to_string()) + } else { + None + } + }) + .collect(); + if numbers.is_empty() { + None + } else { + Some(format!("({})", numbers.join(", "))) + } + } else { + None + }; + + PatchsetDetailsViewModel { + patch_title: details.representative_patch.title().clone(), + author_name: details.representative_patch.author().name.clone(), + version: details.representative_patch.version(), + patch_count: details.representative_patch.total_in_series(), + last_updated: details.representative_patch.updated().clone(), + tag_trailer_counts: TagTrailerCounts { + reviewed_by: details.reviewed_by[i].len(), + tested_by: details.tested_by[i].len(), + acked_by: details.acked_by[i].len(), + }, + staged_to_reply, + preview_entries: details + .patches_preview + .iter() + .map(|s| s.as_str().into_text().unwrap_or_default()) + .collect(), + preview_index: i, + preview_scroll_offset: details.preview_scroll_offset, + preview_pan: details.preview_pan, + preview_fullscreen: details.preview_fullscreen, + preview_title, + is_bookmarked: *details + .patchset_actions + .get(&PatchsetAction::Bookmark) + .unwrap_or(&false), + is_apply_staged: *details + .patchset_actions + .get(&PatchsetAction::Apply) + .unwrap_or(&false), + is_current_patch_reply_staged: *details.patches_to_reply.get(i).unwrap_or(&false), + } } } -fn project_edit_config(state: &AppState) -> EditConfigViewModel { - let ec = state - .config_state - .edit_config - .as_ref() - .expect("EditConfig must be initialised before projecting"); - - let is_editing_mode = ec.is_editing(); - let highlighted = ec.highlighted(); - let editing_tree_selector = is_editing_mode && ec.highlighted_is_tree_selector(); - - let entries = (0..ec.config_count()) - .filter_map(|i| { - ec.config(i).map(|(label, value)| { - let is_highlighted = i == highlighted; - let is_editing = is_editing_mode && is_highlighted; - let edit_cursor_value = if is_editing { - if editing_tree_selector && ec.curr_edit().is_empty() { - "".to_string() +impl From<&AppState> for EditConfigViewModel { + fn from(state: &AppState) -> Self { + let ec = state + .config_state + .edit_config + .as_ref() + .expect("EditConfig must be initialised before projecting"); + + let is_editing_mode = ec.is_editing(); + let highlighted = ec.highlighted(); + let editing_tree_selector = is_editing_mode && ec.highlighted_is_tree_selector(); + + let entries = (0..ec.config_count()) + .filter_map(|i| { + ec.config(i).map(|(label, value)| { + let is_highlighted = i == highlighted; + let is_editing = is_editing_mode && is_highlighted; + let edit_cursor_value = if is_editing { + if editing_tree_selector && ec.curr_edit().is_empty() { + "".to_string() + } else { + ec.curr_edit().to_string() + } } else { - ec.curr_edit().to_string() + String::new() + }; + ConfigEntryRow { + label, + value, + is_highlighted, + is_editing, + edit_cursor_value, } - } else { - String::new() - }; - ConfigEntryRow { - label, - value, - is_highlighted, - is_editing, - edit_cursor_value, - } + }) }) - }) - .collect(); + .collect(); - EditConfigViewModel { - entries, - is_editing_mode, - editing_tree_selector, + EditConfigViewModel { + entries, + is_editing_mode, + editing_tree_selector, + } } } -fn project_kw_ops(state: &AppState) -> KwOpsViewModel { - let ops = state - .kw - .ops - .as_ref() - .expect("KwOps must be initialised before projecting"); - let running = matches!( - state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ); - let start_requested = ops.start_requested; - let restore_branch = state - .kw - .status - .as_ref() - .and_then(|status| status.restore_branch.clone()); - let branch = if ops.editing && ops.focus == KwOpsFocus::Branch { - ops.edit_buffer.clone() - } else { - ops.branch.clone() - }; - let extra_args = if ops.editing && ops.focus == KwOpsFocus::ExtraArgs { - ops.edit_buffer.clone() - } else { - ops.extra_args.clone() - }; - let branch_empty = ops.branch.trim().is_empty(); - let kw_available = ops.readiness.kw_binary.available; - let start_block = start_block_reason(running, start_requested, branch_empty, kw_available); - let start_label = action_label(start_block.clone(), 'b'); - let remote_block = match &ops.readiness.deploy_remote { - Err(reason) => Some(format!("unavailable ({})", compact_remote_refusal(reason))), - Ok(_) => None, - }; - let deploy_block = start_block - .clone() - .or_else(|| remote_block.clone()) - .or_else(|| match &ops.readiness.deploy_alone { +impl From<&AppState> for KwOpsViewModel { + fn from(state: &AppState) -> Self { + let ops = state + .kw + .ops + .as_ref() + .expect("KwOps must be initialised before projecting"); + let running = matches!( + state.kw.status.as_ref().map(|status| &status.job), + Some(KwJobStatus::Running { .. }) + ); + let start_requested = ops.start_requested; + let restore_branch = state + .kw + .status + .as_ref() + .and_then(|status| status.restore_branch.clone()); + let branch = if ops.editing && ops.focus == KwOpsFocus::Branch { + ops.edit_buffer.clone() + } else { + ops.branch.clone() + }; + let extra_args = if ops.editing && ops.focus == KwOpsFocus::ExtraArgs { + ops.edit_buffer.clone() + } else { + ops.extra_args.clone() + }; + let branch_empty = ops.branch.trim().is_empty(); + let kw_available = ops.readiness.kw_binary.available; + let start_block = ViewModelFormatService::find_start_block_reason( + running, + start_requested, + branch_empty, + kw_available, + ); + let start_label = ViewModelFormatService::format_action_label(start_block.clone(), 'b'); + let remote_block = match &ops.readiness.deploy_remote { Err(reason) => Some(format!( "unavailable ({})", - compact_deploy_alone_refusal(reason) + ViewModelFormatService::compact_remote_refusal(reason) )), - Ok(()) => None, - }); - let deploy_label = action_label(deploy_block, 'd'); - let build_deploy_label = action_label(start_block.or(remote_block), 'D'); - let cancel_label = if running { - if ops.cancel_requested { - "requested; waiting for the job to stop".to_string() + Ok(_) => None, + }; + let deploy_block = start_block + .clone() + .or_else(|| remote_block.clone()) + .or_else(|| match &ops.readiness.deploy_alone { + Err(reason) => Some(format!( + "unavailable ({})", + ViewModelFormatService::compact_deploy_alone_refusal(reason) + )), + Ok(()) => None, + }); + let deploy_label = ViewModelFormatService::format_action_label(deploy_block, 'd'); + let build_deploy_label = + ViewModelFormatService::format_action_label(start_block.or(remote_block), 'D'); + let cancel_label = if running { + if ops.cancel_requested { + "requested; waiting for the job to stop".to_string() + } else { + "available (c)".to_string() + } } else { - "available (c)".to_string() - } - } else { - "unavailable".to_string() - }; - let restore_label = match (running, restore_branch.as_deref()) { - (true, _) => "unavailable (a job is running)".to_string(), - (false, Some(branch)) => format!("available (r) to {branch}"), - (false, None) => "unavailable".to_string(), - }; - let job = state.kw.status.as_ref().map(|status| &status.job); - let (warnings, first_error) = match job { - Some(KwJobStatus::Succeeded { warnings, .. }) if !warnings.is_empty() => { - (Some(warnings.join(" | ")), None) - } - Some(KwJobStatus::Failed { first_error, .. }) => (None, first_error.clone()), - _ => (None, None), - }; - let log_path = job - .and_then(KwJobStatus::log_path) - .map(|path| path.display().to_string()); - let log_tail = if ops.log_tail.is_empty() { - if running { - "Waiting for kw output…".to_string() + "unavailable".to_string() + }; + let restore_label = match (running, restore_branch.as_deref()) { + (true, _) => "unavailable (a job is running)".to_string(), + (false, Some(branch)) => format!("available (r) to {branch}"), + (false, None) => "unavailable".to_string(), + }; + let job = state.kw.status.as_ref().map(|status| &status.job); + let (warnings, first_error) = match job { + Some(KwJobStatus::Succeeded { warnings, .. }) if !warnings.is_empty() => { + (Some(warnings.join(" | ")), None) + } + Some(KwJobStatus::Failed { first_error, .. }) => (None, first_error.clone()), + _ => (None, None), + }; + let log_path = job + .and_then(KwJobStatus::log_path) + .map(|path| path.display().to_string()); + let log_tail = if ops.log_tail.is_empty() { + if running { + "Waiting for kw output…".to_string() + } else { + "(no log yet)".to_string() + } } else { - "(no log yet)".to_string() + ops.log_tail.clone() + }; + + KwOpsViewModel { + patchset_title: ops.patchset_title.clone(), + message_id: ops.message_id.clone(), + kernel_tree_id: ops.kernel_tree_id.clone(), + tree_path: ops.tree.path().clone(), + branch, + extra_args, + branch_focused: ops.focus == KwOpsFocus::Branch, + extras_focused: ops.focus == KwOpsFocus::ExtraArgs, + editing: ops.editing, + kw_binary: ViewModelFormatService::format_kw_binary(&ops.readiness.kw_binary), + tree_readiness: ViewModelFormatService::format_tree_readiness(&ops.readiness.tree), + output_dir: ops + .readiness + .output_dir + .as_ref() + .map_or_else(|| "(none)".to_string(), |path| path.display().to_string()), + job_status: if start_requested && !running { + "starting…".to_string() + } else { + ViewModelFormatService::format_job_status(job, ops.cancel_requested) + }, + command: format!( + "kw {}", + argv::KwArgvService::build_argv(&ops.extra_arg_tokens_for_preview()).join(" ") + ), + start_label, + cancel_label, + restore_label, + remote: ViewModelFormatService::format_deploy_remote(&ops.readiness.deploy_remote), + boot_once: ViewModelFormatService::format_boot_once( + ops.readiness.boot_once, + ops.boot_once_acknowledged, + ), + deploy_command: ViewModelFormatService::format_deploy_command( + &ops.readiness.deploy_remote, + state.config.kw_reboot_after_deploy(), + state.config.kw_deploy_force(), + &ops.extra_arg_tokens_for_preview(), + ), + deploy_label, + build_deploy_label, + branch_guidance: if ops.head_unreadable && ops.branch.trim().is_empty() { + Some( + "HEAD is detached or unverifiable; type a branch before starting a job." + .to_string(), + ) + } else { + None + }, + warnings, + first_error, + log_path, + log_tail, } - } else { - ops.log_tail.clone() - }; + } +} - KwOpsViewModel { - patchset_title: ops.patchset_title.clone(), - message_id: ops.message_id.clone(), - kernel_tree_id: ops.kernel_tree_id.clone(), - tree_path: ops.tree.path().clone(), - branch, - extra_args, - branch_focused: ops.focus == KwOpsFocus::Branch, - extras_focused: ops.focus == KwOpsFocus::ExtraArgs, - editing: ops.editing, - kw_binary: format_kw_binary(&ops.readiness.kw_binary), - tree_readiness: format_tree_readiness(&ops.readiness.tree), - output_dir: ops - .readiness - .output_dir - .as_ref() - .map_or_else(|| "(none)".to_string(), |path| path.display().to_string()), - job_status: if start_requested && !running { - "starting…".to_string() - } else { - format_job_status(job, ops.cancel_requested) - }, - command: format!( - "kw {}", - argv::KwArgvService::build_argv(&ops.extra_arg_tokens_for_preview()).join(" ") - ), - start_label, - cancel_label, - restore_label, - remote: format_deploy_remote(&ops.readiness.deploy_remote), - boot_once: format_boot_once(ops.readiness.boot_once, ops.boot_once_acknowledged), - deploy_command: format_deploy_command( - &ops.readiness.deploy_remote, - state.config.kw_reboot_after_deploy(), - state.config.kw_deploy_force(), - &ops.extra_arg_tokens_for_preview(), - ), - deploy_label, - build_deploy_label, - branch_guidance: if ops.head_unreadable && ops.branch.trim().is_empty() { - Some( - "HEAD is detached or unverifiable; type a branch before starting a job." - .to_string(), - ) +struct ViewModelFormatService; + +impl ViewModelFormatService { + fn find_start_block_reason( + running: bool, + start_requested: bool, + branch_empty: bool, + kw_available: bool, + ) -> Option { + if running || start_requested { + Some("unavailable (a job is already running)".to_string()) + } else if !kw_available { + Some("unavailable (kw not on PATH)".to_string()) + } else if branch_empty { + Some("unavailable (set a branch first)".to_string()) } else { None - }, - warnings, - first_error, - log_path, - log_tail, + } } -} -fn start_block_reason( - running: bool, - start_requested: bool, - branch_empty: bool, - kw_available: bool, -) -> Option { - if running || start_requested { - Some("unavailable (a job is already running)".to_string()) - } else if !kw_available { - Some("unavailable (kw not on PATH)".to_string()) - } else if branch_empty { - Some("unavailable (set a branch first)".to_string()) - } else { - None + fn format_action_label(blocked: Option, key: char) -> String { + blocked.unwrap_or_else(|| format!("available ({key})")) } -} -fn action_label(blocked: Option, key: char) -> String { - blocked.unwrap_or_else(|| format!("available ({key})")) -} - -fn format_deploy_remote(remote: &Result) -> String { - match remote { - Ok(remote) => remote.endpoint(), - Err(reason) => reason.to_string(), + fn format_deploy_remote(remote: &Result) -> String { + match remote { + Ok(remote) => remote.endpoint(), + Err(reason) => reason.to_string(), + } } -} -fn format_boot_once(state: BootOnceState, acknowledged: bool) -> String { - match (state, acknowledged) { - (BootOnceState::Off, _) => "off".to_string(), - (BootOnceState::On, true) => "on (confirmed)".to_string(), - (BootOnceState::Unknown, true) => "unknown (confirmed)".to_string(), - (BootOnceState::On, false) => "on (confirm before deploy)".to_string(), - (BootOnceState::Unknown, false) => "unknown (confirm before deploy)".to_string(), + fn format_boot_once(state: BootOnceState, acknowledged: bool) -> String { + match (state, acknowledged) { + (BootOnceState::Off, _) => "off".to_string(), + (BootOnceState::On, true) => "on (confirmed)".to_string(), + (BootOnceState::Unknown, true) => "unknown (confirmed)".to_string(), + (BootOnceState::On, false) => "on (confirm before deploy)".to_string(), + (BootOnceState::Unknown, false) => "unknown (confirm before deploy)".to_string(), + } } -} -fn format_deploy_command( - remote: &Result, - reboot: bool, - force: bool, - extra_args: &[String], -) -> String { - match remote { - Ok(remote) => format!( - "kw {}", - argv::KwArgvService::build_deploy_argv(&remote.endpoint(), reboot, force, extra_args) + fn format_deploy_command( + remote: &Result, + reboot: bool, + force: bool, + extra_args: &[String], + ) -> String { + match remote { + Ok(remote) => format!( + "kw {}", + argv::KwArgvService::build_deploy_argv( + &remote.endpoint(), + reboot, + force, + extra_args + ) .join(" ") - ), - Err(_) => "(no remote)".to_string(), + ), + Err(_) => "(no remote)".to_string(), + } } -} -fn compact_remote_refusal(reason: &RemoteRefusal) -> &'static str { - match reason { - RemoteRefusal::NoRemotesConfigured => "no remotes configured", - RemoteRefusal::NoDefault { .. } => "no default remote", - RemoteRefusal::DefaultNotFound { .. } => "default remote missing", + fn compact_remote_refusal(reason: &RemoteRefusal) -> &'static str { + match reason { + RemoteRefusal::NoRemotesConfigured => "no remotes configured", + RemoteRefusal::NoDefault { .. } => "no default remote", + RemoteRefusal::DefaultNotFound { .. } => "default remote missing", + } } -} -fn compact_deploy_alone_refusal(reason: &DeployAloneRefusal) -> &'static str { - match reason { - DeployAloneRefusal::TreeNotReady(_) => "tree not ready", - DeployAloneRefusal::NoBuildRecord => "no build recorded", - DeployAloneRefusal::LastBuildFailed => "last build failed", - DeployAloneRefusal::HeadMismatch { .. } => "build was on another branch", - DeployAloneRefusal::TreePathDrift { .. } => "tree path changed", - DeployAloneRefusal::OutputDirMismatch => "kw env changed", - DeployAloneRefusal::ImageMissing => "kernel image missing", + fn compact_deploy_alone_refusal(reason: &DeployAloneRefusal) -> &'static str { + match reason { + DeployAloneRefusal::TreeNotReady(_) => "tree not ready", + DeployAloneRefusal::NoBuildRecord => "no build recorded", + DeployAloneRefusal::LastBuildFailed => "last build failed", + DeployAloneRefusal::HeadMismatch { .. } => "build was on another branch", + DeployAloneRefusal::TreePathDrift { .. } => "tree path changed", + DeployAloneRefusal::OutputDirMismatch => "kw env changed", + DeployAloneRefusal::ImageMissing => "kernel image missing", + } } -} -fn format_kw_binary(probe: &crate::kw::models::readiness::KwBinaryProbe) -> String { - if !probe.available { - return "not on PATH".to_string(); - } - match &probe.version_line { - Some(line) => line.clone(), - None => "available (version unknown)".to_string(), + fn format_kw_binary(probe: &crate::kw::models::readiness::KwBinaryProbe) -> String { + if !probe.available { + return "not on PATH".to_string(); + } + match &probe.version_line { + Some(line) => line.clone(), + None => "available (version unknown)".to_string(), + } } -} -fn format_tree_readiness(tree: &TreeReadiness) -> String { - match tree { - TreeReadiness::Ready { arch: Some(arch) } => format!("ready (arch={arch})"), - TreeReadiness::Ready { arch: None } => "ready (arch unset)".to_string(), - other => other.to_string(), + fn format_tree_readiness(tree: &TreeReadiness) -> String { + match tree { + TreeReadiness::Ready { arch: Some(arch) } => format!("ready (arch={arch})"), + TreeReadiness::Ready { arch: None } => "ready (arch unset)".to_string(), + other => other.to_string(), + } } -} -fn format_job_status(job: Option<&KwJobStatus>, cancel_requested: bool) -> String { - match job { - None | Some(KwJobStatus::Idle) => "idle".to_string(), - Some(KwJobStatus::Running { phase, branch, .. }) => { - let phase = match phase { - KwPhase::Building => "building", - KwPhase::Deploying => "deploying", - }; - if cancel_requested { - format!("cancelling {phase} {branch}") - } else { - format!("{phase} {branch}") + fn format_job_status(job: Option<&KwJobStatus>, cancel_requested: bool) -> String { + match job { + None | Some(KwJobStatus::Idle) => "idle".to_string(), + Some(KwJobStatus::Running { phase, branch, .. }) => { + let phase = match phase { + KwPhase::Building => "building", + KwPhase::Deploying => "deploying", + }; + if cancel_requested { + format!("cancelling {phase} {branch}") + } else { + format!("{phase} {branch}") + } } - } - Some(KwJobStatus::Succeeded { - branch, warnings, .. - }) => match warnings.len() { - 0 => format!("succeeded on {branch}"), - 1 => format!("succeeded on {branch} with 1 warning"), - count => format!("succeeded on {branch} with {count} warnings"), - }, - Some(KwJobStatus::Failed { - phase: KwPhase::Deploying, - exit_code, - .. - }) => match exit_code { - Some(code) => match KwJobStatus::find_deploy_exit_hint(*code) { - Some(hint) => format!("failed during deploy (exit {code}: {hint})"), - None => format!("failed during deploy (exit {code})"), + Some(KwJobStatus::Succeeded { + branch, warnings, .. + }) => match warnings.len() { + 0 => format!("succeeded on {branch}"), + 1 => format!("succeeded on {branch} with 1 warning"), + count => format!("succeeded on {branch} with {count} warnings"), }, - None => "failed during deploy (exit unknown)".to_string(), - }, - Some(KwJobStatus::Failed { exit_code, .. }) => match exit_code { - Some(code) => format!("failed (exit {code})"), - None => "failed (exit unknown)".to_string(), - }, - Some(KwJobStatus::Cancelled { .. }) => "cancelled".to_string(), + Some(KwJobStatus::Failed { + phase: KwPhase::Deploying, + exit_code, + .. + }) => match exit_code { + Some(code) => match KwJobStatus::find_deploy_exit_hint(*code) { + Some(hint) => format!("failed during deploy (exit {code}: {hint})"), + None => format!("failed during deploy (exit {code})"), + }, + None => "failed during deploy (exit unknown)".to_string(), + }, + Some(KwJobStatus::Failed { exit_code, .. }) => match exit_code { + Some(code) => format!("failed (exit {code})"), + None => "failed (exit unknown)".to_string(), + }, + Some(KwJobStatus::Cancelled { .. }) => "cancelled".to_string(), + } } } -fn project_popup(popup: &AppPopup) -> PopupViewModel { - match popup { - AppPopup::Info { - title, - body, - scroll, - dimensions, - .. - } => PopupViewModel { - title: title.clone(), - body: PopupViewBody::Text(body.clone()), - scroll_offset: *scroll, - dimensions: *dimensions, - }, - AppPopup::Help { - title, - description, - formatted_keybinds, - scroll, - dimensions, - .. - } => PopupViewModel { - title: title.clone().unwrap_or_else(|| "Help".to_string()), - body: PopupViewBody::Keybinds { - description: description.clone(), - formatted_keybinds: formatted_keybinds.clone(), +impl From<&AppPopup> for PopupViewModel { + fn from(popup: &AppPopup) -> Self { + match popup { + AppPopup::Info { + title, + body, + scroll, + dimensions, + .. + } => PopupViewModel { + title: title.clone(), + body: PopupViewBody::Text(body.clone()), + scroll_offset: *scroll, + dimensions: *dimensions, }, - scroll_offset: *scroll, - dimensions: *dimensions, - }, - AppPopup::ReviewTrailers { - reviewed_by, - tested_by, - acked_by, - scroll, - dimensions, - .. - } => PopupViewModel { - title: "Code-Review Trailers".to_string(), - body: PopupViewBody::ReviewTrailers { - reviewed_by: reviewed_by.clone(), - tested_by: tested_by.clone(), - acked_by: acked_by.clone(), + AppPopup::Help { + title, + description, + formatted_keybinds, + scroll, + dimensions, + .. + } => PopupViewModel { + title: title.clone().unwrap_or_else(|| "Help".to_string()), + body: PopupViewBody::Keybinds { + description: description.clone(), + formatted_keybinds: formatted_keybinds.clone(), + }, + scroll_offset: *scroll, + dimensions: *dimensions, }, - scroll_offset: *scroll, - dimensions: *dimensions, - }, - AppPopup::Confirm { - title, - body, - options, - selected, - dimensions, - } => PopupViewModel { - title: title.clone(), - body: PopupViewBody::Confirm { - body: body.clone(), - options: options.iter().map(|(label, _)| label.clone()).collect(), - selected: *selected, + AppPopup::ReviewTrailers { + reviewed_by, + tested_by, + acked_by, + scroll, + dimensions, + .. + } => PopupViewModel { + title: "Code-Review Trailers".to_string(), + body: PopupViewBody::ReviewTrailers { + reviewed_by: reviewed_by.clone(), + tested_by: tested_by.clone(), + acked_by: acked_by.clone(), + }, + scroll_offset: *scroll, + dimensions: *dimensions, }, - scroll_offset: (0, 0), - dimensions: *dimensions, - }, + AppPopup::Confirm { + title, + body, + options, + selected, + dimensions, + } => PopupViewModel { + title: title.clone(), + body: PopupViewBody::Confirm { + body: body.clone(), + options: options.iter().map(|(label, _)| label.clone()).collect(), + selected: *selected, + }, + scroll_offset: (0, 0), + dimensions: *dimensions, + }, + } } } @@ -657,7 +703,7 @@ mod tests { #[test] fn running_job_projects_a_global_indicator() { - let vm = project_state(&app_state_with_kw(Some(KwStatusSnapshot { + let vm = AppViewModel::from(&app_state_with_kw(Some(KwStatusSnapshot { job: KwJobStatus::Running { kind: KwJobKind::Build, phase: KwPhase::Building, @@ -675,7 +721,7 @@ mod tests { fn confirm_popup_projects_labels_without_actions() { let mut state = app_state_with_kw(None); state.popup = Some(AppPopup::quit_while_job_running()); - let vm = project_state(&state); + let vm = AppViewModel::from(&state); let popup = vm.popup.expect("confirm popup should project"); assert_eq!("Cancel job and quit?", popup.title); let PopupViewBody::Confirm { @@ -712,7 +758,7 @@ mod tests { edit.toggle_editing(); state.config_state.edit_config = Some(edit); - let ScreenViewModel::EditConfig(vm) = project_state(&state).screen else { + let ScreenViewModel::EditConfig(vm) = AppViewModel::from(&state).screen else { panic!("expected EditConfig projection"); }; assert!(vm.editing_tree_selector); @@ -724,7 +770,7 @@ mod tests { fn boot_once_popup_projects_back_out_as_default() { let mut state = app_state_with_kw(None); state.popup = Some(AppPopup::boot_once_warning()); - let vm = project_state(&state); + let vm = AppViewModel::from(&state); let popup = vm.popup.expect("boot-once popup should project"); assert_eq!("Boot into new kernel once?", popup.title); let PopupViewBody::Confirm { @@ -769,7 +815,7 @@ mod tests { ops.extra_args = "--verbose --clean --from-sha abc --doc".to_string(); state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("kw build --verbose", vm.command); @@ -821,7 +867,7 @@ mod tests { ops.branch.clear(); state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!(None, vm.branch_guidance); @@ -836,7 +882,7 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; state.kw.ops = Some(sample_kw_ops(None)); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert!(vm @@ -859,7 +905,7 @@ mod tests { ops.append_edit('8'); state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("kw build --verbose -j8", vm.command); @@ -874,7 +920,7 @@ mod tests { ops.start_requested = true; state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("starting…", vm.job_status); @@ -906,7 +952,7 @@ mod tests { ops.readiness.boot_once = crate::kw::models::readiness::BootOnceState::On; state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("root@box:22", vm.remote); @@ -930,7 +976,7 @@ mod tests { ops.extra_args = "--verbose --local --ccache".to_string(); state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("off", vm.boot_once); @@ -953,7 +999,7 @@ mod tests { ops.boot_once_acknowledged = true; state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("unknown (confirmed)", vm.boot_once); @@ -973,7 +1019,7 @@ mod tests { ops.readiness.deploy_remote = Ok(sample_remote()); state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("kw deploy --remote root@box:22 --reboot", vm.deploy_command); @@ -996,7 +1042,7 @@ mod tests { ops.readiness.deploy_remote = Ok(sample_remote()); state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!( @@ -1020,7 +1066,7 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; state.kw.ops = Some(sample_kw_ops(Some("feature"))); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("failed (exit 1)", vm.job_status); @@ -1043,7 +1089,7 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; state.kw.ops = Some(sample_kw_ops(Some("feature"))); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("failed (exit 2)", vm.job_status); @@ -1077,7 +1123,7 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; state.kw.ops = Some(sample_kw_ops(Some("feature"))); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("succeeded on patchset-x with 2 warnings", vm.job_status); @@ -1099,14 +1145,14 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; state.kw.ops = Some(sample_kw_ops(Some("feature"))); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("succeeded on patchset-x", vm.job_status); assert_eq!(None, vm.warnings); state.kw.status = Some(succeeded_deploy(&["GRUB did not list kernel 7.2.0"])); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("succeeded on patchset-x with 1 warning", vm.job_status); @@ -1118,7 +1164,7 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; state.kw.ops = Some(sample_kw_ops(Some("feature"))); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!(None, vm.log_path); @@ -1128,14 +1174,17 @@ mod tests { #[test] fn idle_succeeded_and_missing_status_have_no_global_indicator() { - assert_eq!(None, project_state(&app_state_with_kw(None)).kw_running); assert_eq!( None, - project_state(&app_state_with_kw(Some(KwStatusSnapshot::idle()))).kw_running + AppViewModel::from(&app_state_with_kw(None)).kw_running + ); + assert_eq!( + None, + AppViewModel::from(&app_state_with_kw(Some(KwStatusSnapshot::idle()))).kw_running ); assert_eq!( None, - project_state(&app_state_with_kw(Some(KwStatusSnapshot { + AppViewModel::from(&app_state_with_kw(Some(KwStatusSnapshot { job: KwJobStatus::Succeeded { kind: KwJobKind::Build, kernel_tree_id: "mainline".to_string(), @@ -1158,7 +1207,7 @@ mod tests { ops.readiness.kw_binary.version_line = None; state.kw.ops = Some(ops); - let ScreenViewModel::KwOps(vm) = project_state(&state).screen else { + let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { panic!("expected KwOps projection"); }; assert_eq!("unavailable (kw not on PATH)", vm.start_label); From 2ebf661d95514f6209af6ab0d80e36e0e706f26e Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 12:06:52 -0300 Subject: [PATCH 44/61] refactor: replace remaining manual converters with From Cache keys are projections of their sources. Reviewed-reply indexes became `collect_successful_indexes`. The preview constructor keeps its arguments and takes a verb name. Signed-off-by: lorenzoberts --- src/app/actions/reviewed_reply.rs | 8 ++++---- src/app/integration_tests/kw_ops.rs | 2 +- src/app/integration_tests/patchset_actions.rs | 4 ++-- src/app/mod.rs | 4 ++-- src/app/screens/details_actions.rs | 2 +- src/lore/application/cache.rs | 13 ++----------- src/lore/application/models/cache.rs | 9 +++++++++ src/lore/application/service.rs | 8 ++++---- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/app/actions/reviewed_reply.rs b/src/app/actions/reviewed_reply.rs index af24855..db7a5b5 100644 --- a/src/app/actions/reviewed_reply.rs +++ b/src/app/actions/reviewed_reply.rs @@ -21,7 +21,7 @@ pub(crate) enum ReviewedReplyResult { } impl ReviewedReplyResult { - pub(crate) fn into_successful_indexes(self) -> HashSet { + pub(crate) fn collect_successful_indexes(self) -> HashSet { match self { ReviewedReplyResult::NoAction { successful_indexes } | ReviewedReplyResult::MissingGitIdentity { successful_indexes } @@ -213,11 +213,11 @@ mod tests { successful_indexes: HashSet::from([3]), }; - assert_eq!(HashSet::from([1]), no_action.into_successful_indexes()); + assert_eq!(HashSet::from([1]), no_action.collect_successful_indexes()); assert_eq!( HashSet::from([2]), - missing_identity.into_successful_indexes() + missing_identity.collect_successful_indexes() ); - assert_eq!(HashSet::from([3]), completed.into_successful_indexes()); + assert_eq!(HashSet::from([3]), completed.collect_successful_indexes()); } } diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index b421496..9ff96d6 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -36,7 +36,7 @@ async fn open_kw_ops_without_actor_stays_on_details() { } fn details_state() -> PatchsetDetailsState { - PatchsetDetailsState::from_rendered_preview( + PatchsetDetailsState::build_from_rendered_preview( sample_patch(), sample_patchset_details(), sample_rendered_preview(), diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index 5f102b9..c6747aa 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -541,7 +541,7 @@ fn app_with_details_and_kw( } fn apply_details_state() -> PatchsetDetailsState { - let mut details = PatchsetDetailsState::from_rendered_preview( + let mut details = PatchsetDetailsState::build_from_rendered_preview( sample_patch(), sample_patchset_details(), sample_rendered_preview(), @@ -553,7 +553,7 @@ fn apply_details_state() -> PatchsetDetailsState { } fn reviewed_reply_details_state() -> PatchsetDetailsState { - let mut details = PatchsetDetailsState::from_rendered_preview( + let mut details = PatchsetDetailsState::build_from_rendered_preview( sample_patch(), sample_patchset_details(), sample_rendered_preview(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 37ec13e..8d6e5c9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -296,7 +296,7 @@ impl App { .map_err(|e| eyre!("{e}"))?; debug!(msg_id, "patchset details loaded"); - self.state.lore.details = Some(PatchsetDetailsState::from_rendered_preview( + self.state.lore.details = Some(PatchsetDetailsState::build_from_rendered_preview( representative_patch, details, rendered_preview, @@ -437,7 +437,7 @@ impl App { self.state .user_state .reviewed_patchsets - .insert(message_id.clone(), result.into_successful_indexes()); + .insert(message_id.clone(), result.collect_successful_indexes()); self.services .lore_api diff --git a/src/app/screens/details_actions.rs b/src/app/screens/details_actions.rs index 5aba6f9..e1b8e56 100644 --- a/src/app/screens/details_actions.rs +++ b/src/app/screens/details_actions.rs @@ -52,7 +52,7 @@ pub enum PatchsetAction { } impl PatchsetDetailsState { - pub fn from_rendered_preview( + pub fn build_from_rendered_preview( representative_patch: Patch, details: PatchsetDetails, rendered_preview: RenderedPatchsetPreview, diff --git a/src/lore/application/cache.rs b/src/lore/application/cache.rs index 354a786..18d9913 100644 --- a/src/lore/application/cache.rs +++ b/src/lore/application/cache.rs @@ -5,11 +5,11 @@ use std::{ use crate::lore::{ application::dto::PatchTagSummary, - domain::{mailing_list::MailingList, patch::Patch, patchset::PatchFeedIndex}, + domain::{mailing_list::MailingList, patchset::PatchFeedIndex}, }; use crate::lore::application::models::cache::{ - FeedCacheEntry, LoreCache, MailingListsCacheEntry, PatchsetCacheEntry, PatchsetCacheKey, + FeedCacheEntry, LoreCache, MailingListsCacheEntry, PatchsetCacheEntry, }; // ── Mailing lists cache ─────────────────────────────────────────────────────── @@ -44,15 +44,6 @@ impl FeedCacheEntry { // ── Patchset cache ──────────────────────────────────────────────────────────── -impl PatchsetCacheKey { - pub fn from_patch(patch: &Patch) -> Self { - PatchsetCacheKey { - message_id: patch.message_id().href.clone(), - version: patch.version(), - } - } -} - impl PatchsetCacheEntry { pub fn new( patchset_path: String, diff --git a/src/lore/application/models/cache.rs b/src/lore/application/models/cache.rs index 37a45b6..b74d56e 100644 --- a/src/lore/application/models/cache.rs +++ b/src/lore/application/models/cache.rs @@ -67,6 +67,15 @@ pub struct PatchsetCacheKey { pub version: usize, } +impl From<&Patch> for PatchsetCacheKey { + fn from(patch: &Patch) -> Self { + Self { + message_id: patch.message_id().href.clone(), + version: patch.version(), + } + } +} + pub struct PatchsetCacheEntry { pub patchset_path: String, pub raw_patches: Vec, diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index 3cc266e..d971b0d 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -256,7 +256,7 @@ impl LoreService { representative_patch: &Patch, mode: CacheMode, ) -> Result { - let key = PatchsetCacheKey::from_patch(representative_patch); + let key = PatchsetCacheKey::from(representative_patch); match mode { CacheMode::Refresh => { @@ -842,7 +842,7 @@ mod tests { MockPatchsetParser::new(), // no expectations ); - let key = PatchsetCacheKey::from_patch(&patch); + let key = PatchsetCacheKey::from(&patch); svc.cache.patchsets.insert( key, PatchsetCacheEntry::new( @@ -891,7 +891,7 @@ mod tests { .unwrap(); assert_eq!("/tmp/new.mbx", details.patchset_path); // The result should now be in cache. - let key = PatchsetCacheKey::from_patch(&patch); + let key = PatchsetCacheKey::from(&patch); assert!(svc.cache.patchsets.contains_key(&key)); } @@ -923,7 +923,7 @@ mod tests { ); // Pre-populate the cache. - let key = PatchsetCacheKey::from_patch(&patch); + let key = PatchsetCacheKey::from(&patch); svc.cache.patchsets.insert( key.clone(), PatchsetCacheEntry::new("/tmp/old.mbx".to_string(), vec![], vec![]), From 1eb8b0125a8c972f8434edd177e0149b1af11114 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 12:45:00 -0300 Subject: [PATCH 45/61] refactor(config): validate update drafts without match guards ConfigService::parse_non_empty replaces the eight blank-string match guards in validate_update. None still skips the field. A blank Some still takes that field's rejection. A non-blank Some still takes the old unguarded arm. page_size: blank is InvalidPageSize of the raw text; non-blank parses the trimmed text as usize and reports InvalidPageSize of the raw text on failure. cache_dir: blank is InvalidDirectory("cache directory is empty"); non-blank still trims and validate_dir. data_dir: blank is InvalidDirectory("data directory is empty"); non-blank still trims and validate_dir. max_log_age: blank is InvalidMaxLogAge of the raw text; non-blank parses the trimmed text as usize. stay_on_applied_branch: blank is InvalidStayOnAppliedBranch of the raw text; non-blank parses the trimmed text as bool. kw_reboot_after_deploy: blank is InvalidKwRebootAfterDeploy of the raw text; non-blank parses the trimmed text as bool. kw_deploy_force: blank is InvalidKwDeployForce of the raw text; non-blank parses the trimmed text as bool. target_kernel_tree: blank is still Some(None); non-blank still accepts a trimmed known key as Some(Some(key)) or InvalidTargetKernelTree. Signed-off-by: lorenzoberts --- src/config/service.rs | 184 ++++++++++++++++++++++-------------------- 1 file changed, 95 insertions(+), 89 deletions(-) diff --git a/src/config/service.rs b/src/config/service.rs index f59d39b..167b125 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -52,45 +52,43 @@ impl ConfigService { fs: &dyn FileSystemTrait, current: &ConfigState, ) -> Result { - let page_size = match &draft.page_size { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidPageSize(s.clone())); - } - Some(s) => Some( - s.trim() + let page_size = Self::parse_non_empty( + &draft.page_size, + |raw| Err(ConfigError::InvalidPageSize(raw.to_string())), + |raw| { + raw.trim() .parse::() - .map_err(|_| ConfigError::InvalidPageSize(s.clone()))?, - ), - }; - - let cache_dir = match &draft.cache_dir { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidDirectory( + .map_err(|_| ConfigError::InvalidPageSize(raw.to_string())) + }, + )?; + + let cache_dir = Self::parse_non_empty( + &draft.cache_dir, + |_| { + Err(ConfigError::InvalidDirectory( "cache directory is empty".into(), - )); - } - Some(s) => { - let t = s.trim(); - Self::validate_dir(fs, t)?; - Some(t.to_string()) - } - }; - - let data_dir = match &draft.data_dir { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidDirectory( + )) + }, + |raw| { + let trimmed = raw.trim(); + Self::validate_dir(fs, trimmed)?; + Ok(trimmed.to_string()) + }, + )?; + + let data_dir = Self::parse_non_empty( + &draft.data_dir, + |_| { + Err(ConfigError::InvalidDirectory( "data directory is empty".into(), - )); - } - Some(s) => { - let t = s.trim(); - Self::validate_dir(fs, t)?; - Some(t.to_string()) - } - }; + )) + }, + |raw| { + let trimmed = raw.trim(); + Self::validate_dir(fs, trimmed)?; + Ok(trimmed.to_string()) + }, + )?; let git_send_email_option = draft.git_send_email_option.clone(); let git_am_option = draft.git_am_option.clone(); @@ -105,66 +103,58 @@ impl ConfigService { Some(s) => Some(ConfigParsingService::parse_cover_renderer(s)?), }; - let max_log_age = match &draft.max_log_age { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidMaxLogAge(s.clone())); - } - Some(s) => Some( - s.trim() + let max_log_age = Self::parse_non_empty( + &draft.max_log_age, + |raw| Err(ConfigError::InvalidMaxLogAge(raw.to_string())), + |raw| { + raw.trim() .parse::() - .map_err(|_| ConfigError::InvalidMaxLogAge(s.clone()))?, - ), - }; - - let stay_on_applied_branch = match &draft.stay_on_applied_branch { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidStayOnAppliedBranch(s.clone())); - } - Some(s) => Some( - s.trim() + .map_err(|_| ConfigError::InvalidMaxLogAge(raw.to_string())) + }, + )?; + + let stay_on_applied_branch = Self::parse_non_empty( + &draft.stay_on_applied_branch, + |raw| Err(ConfigError::InvalidStayOnAppliedBranch(raw.to_string())), + |raw| { + raw.trim() .parse::() - .map_err(|_| ConfigError::InvalidStayOnAppliedBranch(s.clone()))?, - ), - }; - - let kw_reboot_after_deploy = match &draft.kw_reboot_after_deploy { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidKwRebootAfterDeploy(s.clone())); - } - Some(s) => Some( - s.trim() + .map_err(|_| ConfigError::InvalidStayOnAppliedBranch(raw.to_string())) + }, + )?; + + let kw_reboot_after_deploy = Self::parse_non_empty( + &draft.kw_reboot_after_deploy, + |raw| Err(ConfigError::InvalidKwRebootAfterDeploy(raw.to_string())), + |raw| { + raw.trim() .parse::() - .map_err(|_| ConfigError::InvalidKwRebootAfterDeploy(s.clone()))?, - ), - }; - - let kw_deploy_force = match &draft.kw_deploy_force { - None => None, - Some(s) if s.trim().is_empty() => { - return Err(ConfigError::InvalidKwDeployForce(s.clone())); - } - Some(s) => Some( - s.trim() + .map_err(|_| ConfigError::InvalidKwRebootAfterDeploy(raw.to_string())) + }, + )?; + + let kw_deploy_force = Self::parse_non_empty( + &draft.kw_deploy_force, + |raw| Err(ConfigError::InvalidKwDeployForce(raw.to_string())), + |raw| { + raw.trim() .parse::() - .map_err(|_| ConfigError::InvalidKwDeployForce(s.clone()))?, - ), - }; - - let target_kernel_tree = match &draft.target_kernel_tree { - None => None, - Some(s) if s.trim().is_empty() => Some(None), - Some(s) => { - let key = s.trim(); + .map_err(|_| ConfigError::InvalidKwDeployForce(raw.to_string())) + }, + )?; + + let target_kernel_tree = Self::parse_non_empty( + &draft.target_kernel_tree, + |_| Ok(Some(None)), + |raw| { + let key = raw.trim(); if current.kernel_trees.contains_key(key) { - Some(Some(key.to_string())) + Ok(Some(key.to_string())) } else { - return Err(Self::reject_unknown_kernel_tree(s, current)); + Err(Self::reject_unknown_kernel_tree(raw, current)) } - } - }; + }, + )?; Ok(ValidatedConfigUpdate { page_size, @@ -222,6 +212,22 @@ impl ConfigService { } } + fn parse_non_empty( + value: &Option, + on_blank: impl FnOnce(&str) -> Result, ConfigError>, + on_value: impl FnOnce(&str) -> Result, + ) -> Result, ConfigError> { + if let Some(raw) = value { + if raw.trim().is_empty() { + on_blank(raw) + } else { + on_value(raw).map(Some) + } + } else { + Ok(None) + } + } + fn validate_dir(fs: &dyn FileSystemTrait, dir_path: &str) -> Result<(), ConfigError> { let path = Path::new(dir_path); if fs.exists(path) && fs.is_dir(path) { From 7e9ad55cec3d43759f96b4dec9069691db54d75f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 12:54:57 -0300 Subject: [PATCH 46/61] refactor: match small enums exhaustively instead of guards and matches! Each guard's false case still reaches the same result as the arm it used to fall through to. view_model succeeded-with-warnings: an empty warning list still yields (None, None), which was the wildcard arm. Failed is unchanged. Idle, Running, Cancelled, and a missing job stay (None, None). kw_job_running_popup: Ok with a non-running job still returns None, as does a status error. handle_job_event: an unsuccessful Exited outcome still becomes Failed. WaitFailed and Cancelled are unchanged. probe_head_branch: Ok with success still returns the trimmed branch; Ok without success still warns and returns an empty string. Err is unchanged. resolve_output_dir: an empty XDG_CACHE_HOME still uses $HOME/.cache, and so does a missing variable. A non-empty value is still used as-is, and HOME is still read only on the fallback. check_kw_version: a parsed version below the floor is still Below. None is still Unknown. A version at or above the floor is still Meets. resolve_xdg_kw_config_file and resolve_xdg_remote_config: an empty XDG_CONFIG_HOME still uses $HOME/.config via HOME.ok(), and so does a missing variable. terminal_event_from_crossterm_event: a non-release key still becomes KeyInput. Release is still dropped. The wildcard for other crossterm events stays. map_tail_read: NotFound is still an empty tail. Any other IoError still formats "(could not read log: ...)". map_details_go_to_first_line_chord: a pending chord inside the timeout still fires PreviewGoToFirstLine. An expired chord and no pending chord still arm a new chord and return None. KwJobStatus::running_indicator lists Idle, Succeeded, Failed, and Cancelled instead of a wildcard; only Running still produces text. is_snapshot_job_terminal is true only for Succeeded, Failed, and Cancelled. is_job_running and is_kw_job_running are true only for Running; should_tail_kw_ops uses is_kw_job_running. needs_boot_once_confirm is true for On and Unknown when not yet acknowledged, and false for Off. A deploy start still checks deploy_alone only for KwStartKind::Deploy. An unverified kw version warning still fires for Below and Unknown, not Meets. Boot-once refusal still fires for On and Unknown when the option is not acknowledged. Left in place: the select! precondition in AppActor, KeyCode::Enter while a confirm popup is open (a closed popup still falls through to the wildcard), and the argv pointer-equality guard (a different option still falls through to the ambiguous-prefix rejection). apply_kw_snapshot clears start_requested for every non-Idle status (Running, Succeeded, Failed, and Cancelled). Idle leaves the flag unchanged. KwOpsViewModel.running is true only while the job is Running. Idle, Succeeded, Failed, Cancelled, and a missing status stay false. Signed-off-by: lorenzoberts --- src/app/actor.rs | 20 ++++--- src/app/dependencies.rs | 6 +- src/app/flows/kw_ops.rs | 63 ++++++++++++-------- src/app/mod.rs | 14 +++-- src/app/view_model.rs | 27 ++++++--- src/input/mapper.rs | 18 +++--- src/kw/actor/mod.rs | 128 +++++++++++++++++++++------------------- src/kw/readiness.rs | 35 +++++++---- src/kw/remote.rs | 11 +++- src/kw/status.rs | 5 +- src/terminal/session.rs | 9 ++- 11 files changed, 204 insertions(+), 132 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index e76db9c..8d17a0e 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -218,11 +218,7 @@ impl AppActor { } fn should_tail_kw_ops(app: &App) -> bool { - app.state.navigation.current_screen == CurrentScreen::KwOps - && matches!( - app.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) + app.state.navigation.current_screen == CurrentScreen::KwOps && Self::is_kw_job_running(app) } /// When the status watch is missing, poll GetStatus while a start is @@ -333,10 +329,16 @@ impl AppActor { } fn is_kw_job_running(app: &App) -> bool { - matches!( - app.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) + match app.state.kw.status.as_ref().map(|status| &status.job) { + Some(KwJobStatus::Running { .. }) => true, + Some( + KwJobStatus::Idle + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. }, + ) + | None => false, + } } /// Cancel then leave. A job that finished while the confirm popup was diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 484329a..1416760 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -66,12 +66,16 @@ impl DependencyService { } let kw = ReadinessService::probe_kw_binary(env, shell); + let kw_version_unconfirmed = match &kw.check { + KwVersionCheck::Meets => false, + KwVersionCheck::Below(_) | KwVersionCheck::Unknown => true, + }; if !kw.available { event!( Level::WARN, "kw is not installed, kernel build/deploy won't work" ); - } else if !matches!(kw.check, KwVersionCheck::Meets) { + } else if kw_version_unconfirmed { event!( Level::WARN, version = kw.version_line.as_deref().unwrap_or("unknown"), diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 579433a..e991790 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -99,7 +99,14 @@ impl App { /// start lock once the actor has left Idle. pub(crate) fn apply_kw_snapshot(&mut self, snapshot: KwStatusSnapshot) { if let Some(ops) = self.state.kw.ops.as_mut() { - if !matches!(snapshot.job, KwJobStatus::Idle) { + let left_idle = match &snapshot.job { + KwJobStatus::Idle => false, + KwJobStatus::Running { .. } + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } => true, + }; + if left_idle { ops.start_requested = false; } } @@ -327,12 +334,13 @@ impl App { fn map_tail_read(result: Result) -> String { match result { Ok(text) => text, - Err(FileSystemError::IoError(error)) - if error.kind() == std::io::ErrorKind::NotFound => - { - String::new() + Err(FileSystemError::IoError(error)) => { + if error.kind() == std::io::ErrorKind::NotFound { + String::new() + } else { + format!("(could not read log: {error})") + } } - Err(error) => format!("(could not read log: {error})"), } } @@ -346,12 +354,12 @@ impl App { } fn is_snapshot_job_terminal(job: &KwJobStatus) -> bool { - matches!( - job, + match job { KwJobStatus::Succeeded { .. } - | KwJobStatus::Failed { .. } - | KwJobStatus::Cancelled { .. } - ) + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } => true, + KwJobStatus::Idle | KwJobStatus::Running { .. } => false, + } } async fn start_job(&mut self, kind: KwStartKind) -> Result<()> { @@ -376,11 +384,14 @@ impl App { } // Record/tree refusals before the boot-once confirm so a deploy // without a build does not ask the user to proceed, then refuse. - if matches!(kind, KwStartKind::Deploy) { - if let Err(reason) = &ops.readiness.deploy_alone { - self.state.popup = Some(AppPopup::info(kind.title(), reason.to_string())); - return Ok(()); + match kind { + KwStartKind::Deploy => { + if let Err(reason) = &ops.readiness.deploy_alone { + self.state.popup = Some(AppPopup::info(kind.title(), reason.to_string())); + return Ok(()); + } } + KwStartKind::Build | KwStartKind::BuildThenDeploy => {} } if kind.pending_kind().is_some() && Self::needs_boot_once_confirm(ops) { if let Some(ops) = self.state.kw.ops.as_mut() { @@ -445,10 +456,10 @@ impl App { } fn needs_boot_once_confirm(ops: &KwOpsState) -> bool { - matches!( - ops.readiness.boot_once, - BootOnceState::On | BootOnceState::Unknown - ) && !ops.boot_once_acknowledged + match ops.readiness.boot_once { + BootOnceState::Off => false, + BootOnceState::On | BootOnceState::Unknown => !ops.boot_once_acknowledged, + } } async fn refresh_kw_ops_readiness(&mut self) { @@ -546,10 +557,16 @@ impl App { } fn is_job_running(&self) -> bool { - matches!( - self.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) + match self.state.kw.status.as_ref().map(|status| &status.job) { + Some(KwJobStatus::Running { .. }) => true, + Some( + KwJobStatus::Idle + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. }, + ) + | None => false, + } } fn is_job_busy(&self) -> bool { diff --git a/src/app/mod.rs b/src/app/mod.rs index 8d6e5c9..60cb9ce 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -494,13 +494,17 @@ impl App { async fn kw_job_running_popup(&self) -> Option { let kw = self.services.kw.as_ref()?; match kw.get_status().await { - Ok(snapshot) if matches!(snapshot.job, KwJobStatus::Running { .. }) => { - Some(models::popup::AppPopup::info( + Ok(snapshot) => match snapshot.job { + KwJobStatus::Running { .. } => Some(models::popup::AppPopup::info( "Patchset Apply Blocked", " A kw job is running on the kernel tree.\n\nApplying a patchset now would rewrite the branch the job is building under it.\n\nWait for the job to finish, then apply again.", - )) - } - _ => None, + )), + KwJobStatus::Idle + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } => None, + }, + Err(_) => None, } } diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 887b31f..6fff901 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -302,10 +302,16 @@ impl From<&AppState> for KwOpsViewModel { .ops .as_ref() .expect("KwOps must be initialised before projecting"); - let running = matches!( - state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ); + let running = match state.kw.status.as_ref().map(|status| &status.job) { + Some(KwJobStatus::Running { .. }) => true, + Some( + KwJobStatus::Idle + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. }, + ) + | None => false, + }; let start_requested = ops.start_requested; let restore_branch = state .kw @@ -367,11 +373,18 @@ impl From<&AppState> for KwOpsViewModel { }; let job = state.kw.status.as_ref().map(|status| &status.job); let (warnings, first_error) = match job { - Some(KwJobStatus::Succeeded { warnings, .. }) if !warnings.is_empty() => { - (Some(warnings.join(" | ")), None) + Some(KwJobStatus::Succeeded { warnings, .. }) => { + if warnings.is_empty() { + (None, None) + } else { + (Some(warnings.join(" | ")), None) + } } Some(KwJobStatus::Failed { first_error, .. }) => (None, first_error.clone()), - _ => (None, None), + Some( + KwJobStatus::Idle | KwJobStatus::Running { .. } | KwJobStatus::Cancelled { .. }, + ) + | None => (None, None), }; let log_path = job .and_then(KwJobStatus::log_path) diff --git a/src/input/mapper.rs b/src/input/mapper.rs index b8d18ff..e4bb2c9 100644 --- a/src/input/mapper.rs +++ b/src/input/mapper.rs @@ -216,20 +216,16 @@ impl InputMapper { } fn map_details_go_to_first_line_chord(&mut self) -> Option { - match self.pending { - Some(PendingInput::DetailsGoToFirstLine { started_at }) - if started_at.elapsed() <= self.bindings.chord_timeout => - { + if let Some(PendingInput::DetailsGoToFirstLine { started_at }) = self.pending { + if started_at.elapsed() <= self.bindings.chord_timeout { self.pending = None; - Some(InputEvent::PreviewGoToFirstLine) - } - _ => { - self.pending = Some(PendingInput::DetailsGoToFirstLine { - started_at: Instant::now(), - }); - None + return Some(InputEvent::PreviewGoToFirstLine); } } + self.pending = Some(PendingInput::DetailsGoToFirstLine { + started_at: Instant::now(), + }); + None } fn clear_expired_pending_input(&mut self) { diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index f8e9b43..46652ac 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -607,54 +607,55 @@ impl KwActor { // already durable. self.record_build_outcome(&job, &outcome); let status = match outcome { - JobOutcome::Exited(exit) if exit.success() => { - // kw deploy exits 0 through initramfs and GRUB - // failures; only its log tells. - let warnings = if job.phase == KwPhase::Deploying { - let kernelrelease = job - .deploy - .as_ref() - .and_then(|deploy| deploy.kernelrelease.as_deref()); - self.read_job_log(&job.log_path) - .map(|log| { - log_scan::LogScanService::collect_deploy_warnings( - &log, - kernelrelease, - ) - }) - .unwrap_or_default() - } else { - Vec::new() - }; - tracing::info!( - kernel_tree_id = job.kernel_tree_id, - branch = job.branch, - warnings = warnings.len(), - "kw job succeeded" - ); - KwJobStatus::Succeeded { - kind: job.kind, - kernel_tree_id: job.kernel_tree_id, - branch: job.branch, - log_path: job.log_path, - warnings, - } - } JobOutcome::Exited(exit) => { - tracing::warn!( - kernel_tree_id = job.kernel_tree_id, - branch = job.branch, - exit_code = exit.code(), - "kw job failed" - ); - KwJobStatus::Failed { - kind: job.kind, - phase: job.phase, - exit_code: exit.code(), - first_error: self - .read_job_log(&job.log_path) - .and_then(|log| log_scan::LogScanService::find_first_error(&log)), - log_path: job.log_path, + if exit.success() { + // kw deploy exits 0 through initramfs and GRUB + // failures; only its log tells. + let warnings = if job.phase == KwPhase::Deploying { + let kernelrelease = job + .deploy + .as_ref() + .and_then(|deploy| deploy.kernelrelease.as_deref()); + self.read_job_log(&job.log_path) + .map(|log| { + log_scan::LogScanService::collect_deploy_warnings( + &log, + kernelrelease, + ) + }) + .unwrap_or_default() + } else { + Vec::new() + }; + tracing::info!( + kernel_tree_id = job.kernel_tree_id, + branch = job.branch, + warnings = warnings.len(), + "kw job succeeded" + ); + KwJobStatus::Succeeded { + kind: job.kind, + kernel_tree_id: job.kernel_tree_id, + branch: job.branch, + log_path: job.log_path, + warnings, + } + } else { + tracing::warn!( + kernel_tree_id = job.kernel_tree_id, + branch = job.branch, + exit_code = exit.code(), + "kw job failed" + ); + KwJobStatus::Failed { + kind: job.kind, + phase: job.phase, + exit_code: exit.code(), + first_error: self.read_job_log(&job.log_path).and_then(|log| { + log_scan::LogScanService::find_first_error(&log) + }), + log_path: job.log_path, + } } } JobOutcome::WaitFailed(error) => { @@ -993,9 +994,11 @@ impl KwActor { kernelrelease = record.and_then(|record| record.kernelrelease); } let boot_once = readiness::ReadinessService::probe_boot_once(fs, env, tree_path); - if matches!(boot_once, BootOnceState::On | BootOnceState::Unknown) - && !options.boot_once_acknowledged - { + let boot_once_open = match boot_once { + BootOnceState::Off => false, + BootOnceState::On | BootOnceState::Unknown => true, + }; + if boot_once_open && !options.boot_once_acknowledged { return Err(KwStartError::BootOnceNotAcknowledged); } Ok(JobDeploy { @@ -1061,10 +1064,14 @@ impl KwActor { /// outcome to [`JobOutcome::Cancelled`] also skips the build record, matching /// the "cancel in Building writes nothing" rule. fn rewrite_outcome_after_building_cancel(job: &JobState, outcome: JobOutcome) -> JobOutcome { + let build_exited_successfully = match &outcome { + JobOutcome::Exited(exit) => exit.success(), + JobOutcome::WaitFailed(_) | JobOutcome::Cancelled => false, + }; if job.cancel_tx.is_none() && job.kind == KwJobKind::BuildThenDeploy && job.phase == KwPhase::Building - && matches!(&outcome, JobOutcome::Exited(exit) if exit.success()) + && build_exited_successfully { JobOutcome::Cancelled } else { @@ -1165,16 +1172,17 @@ impl KwGitService { fn probe_head_branch(shell: &dyn ShellTrait, tree_path: &str) -> String { let cmd = ShellCommand::new("git").args(["-C", tree_path, "branch", "--show-current"]); match shell.execute(&cmd) { - Ok(output) if output.success => { - String::from_utf8_lossy(&output.stdout).trim().to_string() - } Ok(output) => { - tracing::warn!( - tree = tree_path, - stderr = %String::from_utf8_lossy(&output.stderr), - "failed to probe the kernel tree's HEAD branch" - ); - String::new() + if output.success { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + tracing::warn!( + tree = tree_path, + stderr = %String::from_utf8_lossy(&output.stderr), + "failed to probe the kernel tree's HEAD branch" + ); + String::new() + } } Err(error) => { tracing::warn!(tree = tree_path, %error, "failed to probe the kernel tree's HEAD branch"); diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 2c4e148..7082805 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -214,11 +214,16 @@ impl ReadinessService { return Ok(None); } - let cache_base = match env.var("XDG_CACHE_HOME") { - // bash's `:-` (and the XDG spec) treat a set-but-empty value as - // unset; env::var would happily return it as Ok(""). - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.cache", env.var("HOME")?), + // bash's `:-` (and the XDG spec) treat a set-but-empty value as + // unset; env::var would happily return it as Ok(""). + let cache_base = if let Ok(xdg) = env.var("XDG_CACHE_HOME") { + if xdg.is_empty() { + format!("{}/.cache", env.var("HOME")?) + } else { + xdg + } + } else { + format!("{}/.cache", env.var("HOME")?) }; let trimmed = tree_path.to_string_lossy(); let normalized = match trimmed.trim_end_matches('/') { @@ -470,8 +475,13 @@ impl ReadinessService { impl ReadinessService { fn check_kw_version(version_line: &str) -> KwVersionCheck { match Self::parse_kw_version(version_line) { - Some(version) if version >= KW_MIN_VERSION => KwVersionCheck::Meets, - Some(_) => KwVersionCheck::Below(version_line.to_string()), + Some(version) => { + if version >= KW_MIN_VERSION { + KwVersionCheck::Meets + } else { + KwVersionCheck::Below(version_line.to_string()) + } + } None => KwVersionCheck::Unknown, } } @@ -530,9 +540,14 @@ impl ReadinessService { /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG /// spec. fn resolve_xdg_kw_config_file(env: &dyn EnvTrait, filename: &str) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), + let config_home = if let Ok(xdg) = env.var("XDG_CONFIG_HOME") { + if xdg.is_empty() { + format!("{}/.config", env.var("HOME").ok()?) + } else { + xdg + } + } else { + format!("{}/.config", env.var("HOME").ok()?) }; Some(Path::new(&config_home).join("kw").join(filename)) } diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 36ad75f..e2db2bc 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -171,9 +171,14 @@ impl RemoteConfigService { /// `XDG_CONFIG_HOME` is treated as unset, matching bash `:-` and the XDG /// spec — otherwise the path would be relative to cwd. fn resolve_xdg_remote_config(env: &dyn EnvTrait) -> Option { - let config_home = match env.var("XDG_CONFIG_HOME") { - Ok(xdg) if !xdg.is_empty() => xdg, - _ => format!("{}/.config", env.var("HOME").ok()?), + let config_home = if let Ok(xdg) = env.var("XDG_CONFIG_HOME") { + if xdg.is_empty() { + format!("{}/.config", env.var("HOME").ok()?) + } else { + xdg + } + } else { + format!("{}/.config", env.var("HOME").ok()?) }; Some(Path::new(&config_home).join("kw").join("remote.config")) } diff --git a/src/kw/status.rs b/src/kw/status.rs index 9f2c155..fc46068 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -82,7 +82,10 @@ impl KwStatusSnapshot { }; Some(format!("kw: {phase} {branch}")) } - _ => None, + KwJobStatus::Idle + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } => None, } } } diff --git a/src/terminal/session.rs b/src/terminal/session.rs index de35af3..c43feb5 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -45,8 +45,13 @@ impl CrosstermTerminalSession { pub fn terminal_event_from_crossterm_event(event: Event) -> Option { match event { - Event::Key(key) if key.kind == KeyEventKind::Release => None, - Event::Key(key) => Some(TerminalEvent::Key(KeyInput::from(key))), + Event::Key(key) => { + if key.kind == KeyEventKind::Release { + None + } else { + Some(TerminalEvent::Key(KeyInput::from(key))) + } + } Event::Resize(width, height) => Some(TerminalEvent::Resize { width, height }), _ => None, } From 6a92a8380755f4b62698fef155f8d187e5120000 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 13:16:50 -0300 Subject: [PATCH 47/61] style: drop explicit types before collect Bindings no longer spell the collection type before collect. Where the following use does not pin the type, the same type is written as a turbofish. Two sites infer it and drop the annotation. collect::>: edit-config kernel-tree keys, config reject_unknown_kernel_tree keys, merged kw argv, the view-model reply index list, and the bufreader test lines. collect::>: lore tag parts, pre blocks, and patch preview lines. collect::>: reserved short-cluster characters. collect::>: fetched patchset tag summaries. collect::: the minor component of a kw version line. collect::>: the remote-config test filesystem. Inferred with no annotation: kw config keys inserted into HashMap, and get_page's Vec<&Patch> returned as Option>. Signed-off-by: lorenzoberts --- src/app/screens/edit_config.rs | 6 +++++- src/app/view_model.rs | 4 ++-- src/config/service.rs | 6 +++++- src/infrastructure/file_system/tests.rs | 2 +- src/kw/argv.rs | 7 +++++-- src/kw/readiness.rs | 6 +++--- src/kw/remote.rs | 4 ++-- src/lore/application/service.rs | 6 +++--- src/lore/domain/patchset.rs | 2 +- src/lore/infrastructure/parsers.rs | 4 ++-- src/render/patch_renderer.rs | 2 +- 11 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/app/screens/edit_config.rs b/src/app/screens/edit_config.rs index 9d7d55d..b186bf5 100644 --- a/src/app/screens/edit_config.rs +++ b/src/app/screens/edit_config.rs @@ -56,7 +56,11 @@ impl EditConfigState { config.kw_deploy_force().to_string(), ); - let mut keys: Vec = config.kernel_trees().into_iter().cloned().collect(); + let mut keys = config + .kernel_trees() + .into_iter() + .cloned() + .collect::>(); keys.sort(); let raw_target = config .target_kernel_tree() diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 6fff901..28119ea 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -194,7 +194,7 @@ impl From<&AppState> for PatchsetDetailsViewModel { .unwrap_or(&false) { let number_offset = if details.has_cover_letter { 0 } else { 1 }; - let numbers: Vec = details + let numbers = details .patches_to_reply .iter() .enumerate() @@ -205,7 +205,7 @@ impl From<&AppState> for PatchsetDetailsViewModel { None } }) - .collect(); + .collect::>(); if numbers.is_empty() { None } else { diff --git a/src/config/service.rs b/src/config/service.rs index 167b125..32f3a60 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -195,7 +195,11 @@ impl ConfigService { } fn reject_unknown_kernel_tree(raw: &str, current: &ConfigState) -> ConfigError { - let mut keys: Vec = current.kernel_trees.keys().cloned().collect(); + let mut keys = current + .kernel_trees + .keys() + .cloned() + .collect::>(); keys.sort(); let hint = if keys.is_empty() { "no kernel trees are configured; unset the target or add trees in the config file" diff --git a/src/infrastructure/file_system/tests.rs b/src/infrastructure/file_system/tests.rs index 6c3d1b7..196a0be 100644 --- a/src/infrastructure/file_system/tests.rs +++ b/src/infrastructure/file_system/tests.rs @@ -170,7 +170,7 @@ fn open_bufreader_reads_file() { let fs = OsFileSystem; let reader = fs.open_bufreader(&file_path).unwrap(); - let lines: Vec = reader.lines().map(|l| l.unwrap()).collect(); + let lines = reader.lines().map(|l| l.unwrap()).collect::>(); assert_eq!(lines, vec!["line1", "line2"]); } diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 1b0e810..335e981 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -149,7 +149,10 @@ impl KwArgvService { reserved: &[ReservedOption], extra_args: &[String], ) -> Vec { - let mut argv: Vec = base.iter().map(|arg| arg.to_string()).collect(); + let mut argv = base + .iter() + .map(|arg| arg.to_string()) + .collect::>(); let mut extras = extra_args.iter(); while let Some(token) = extras.next() { match Self::find_reserved_option(reserved, token) { @@ -244,7 +247,7 @@ impl KwArgvService { if body.is_empty() || body.starts_with('-') { return None; } - let chars: Vec = body.chars().collect(); + let chars = body.chars().collect::>(); let mut i = 0; let mut hit = None; while i < chars.len() { diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 7082805..54ee19b 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -93,7 +93,7 @@ impl ReadinessService { let Some((key, value)) = uncommented.split_once('=') else { continue; }; - let key: String = key.chars().filter(|c| !c.is_whitespace()).collect(); + let key = key.chars().filter(|c| !c.is_whitespace()).collect(); entries.insert(key, value.trim().to_string()); } entries @@ -492,11 +492,11 @@ impl ReadinessService { let start = line.find(|c: char| c.is_ascii_digit())?; let mut parts = line[start..].splitn(3, '.'); let major = parts.next()?.parse().ok()?; - let minor: String = parts + let minor = parts .next()? .chars() .take_while(|c| c.is_ascii_digit()) - .collect(); + .collect::(); Some((major, minor.parse().ok()?)) } diff --git a/src/kw/remote.rs b/src/kw/remote.rs index e2db2bc..31f7500 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -479,10 +479,10 @@ Host dut extra } fn fs_with_files(files: &[(&str, &str)]) -> MockFileSystemTrait { - let map: HashMap = files + let map = files .iter() .map(|(path, content)| (PathBuf::from(path), content.to_string())) - .collect(); + .collect::>(); let is_file = Arc::new(map.clone()); let read = Arc::new(map); let mut fs = MockFileSystemTrait::new(); diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index d971b0d..dfed9c5 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -291,10 +291,10 @@ impl LoreService { .split_patchset(&patchset_path) .map_err(LoreError::Parse)?; - let tag_summary: Vec = raw_patches + let tag_summary = raw_patches .iter() .map(|p| Self::extract_tag_summary(p)) - .collect(); + .collect::>(); match mode { CacheMode::Bypass => {} @@ -440,7 +440,7 @@ impl LoreService { ("Acked-by:", &mut acked_by), ] { if let Some(rest) = line.strip_prefix(prefix) { - let parts: Vec<&str> = rest.trim().split('<').collect(); + let parts = rest.trim().split('<').collect::>(); if parts.len() == 2 { let name = parts[0].trim().to_string(); let email = parts[1].trim_end_matches('>').trim().to_string(); diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index 95f52e3..1859415 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -64,7 +64,7 @@ impl PatchFeedIndex { upper_end = max_index + 1; } - let page: Vec<&Patch> = (lower_end..upper_end) + let page = (lower_end..upper_end) .filter_map(|i| self.patches_by_id.get(&self.representative_patch_ids[i])) .collect(); diff --git a/src/lore/infrastructure/parsers.rs b/src/lore/infrastructure/parsers.rs index 95df372..d6ff6a3 100644 --- a/src/lore/infrastructure/parsers.rs +++ b/src/lore/infrastructure/parsers.rs @@ -27,10 +27,10 @@ impl LoreParserService { let mut list_descriptions: Vec<&str> = Vec::new(); let mut available_lists: Vec = Vec::new(); - let pre_blocks: Vec<&str> = RE_PRE_BLOCK + let pre_blocks = RE_PRE_BLOCK .captures_iter(html) .filter_map(|cap| cap.get(1).map(|m| m.as_str())) - .collect(); + .collect::>(); if pre_blocks.len() < 3 { return available_lists; diff --git a/src/render/patch_renderer.rs b/src/render/patch_renderer.rs index f2fc515..43cf90c 100644 --- a/src/render/patch_renderer.rs +++ b/src/render/patch_renderer.rs @@ -30,7 +30,7 @@ impl PatchRendererService { /// the trailing signature delimiter (the `--` at the end of the patch) if it /// exists, as it is incorrectly rendered as a deletion by diff renderers. fn clean_patch_for_preview(patch: &str) -> String { - let lines: Vec<&str> = patch.lines().collect(); + let lines = patch.lines().collect::>(); if let Some(sig_pos) = lines.iter().position(|&line| line.trim() == "--") { lines[..sig_pos].join("\n") From 31924f6267a9921edd36fb00703aff3a95325dc9 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 13:53:41 -0300 Subject: [PATCH 48/61] style: import paths instead of fully qualifying them Hoist crate paths and other multi-segment paths into use so call sites name the imported item, or keep a module prefix when that prefix carries the meaning. That covers fs, io, time, task, and process, and names that would mislead if imported bare: fmt::Result, io::Error, and ptr::eq. Doc links drop fully qualified crate paths for the same reason. Signed-off-by: lorenzoberts --- src/app/actions/mod.rs | 13 +- src/app/actor.rs | 52 ++++---- src/app/flows/edit_config.rs | 6 +- src/app/flows/kw_ops.rs | 19 ++- src/app/integration_tests/kw_ops.rs | 122 +++++++++--------- src/app/integration_tests/kw_status.rs | 87 +++++++------ src/app/integration_tests/patchset_actions.rs | 30 +++-- src/app/mod.rs | 27 ++-- src/app/models/kw_ops.rs | 2 +- src/app/models/view_model.rs | 2 +- src/app/view_model.rs | 63 ++++----- src/config/actor.rs | 10 +- src/config/tests.rs | 4 +- src/config/update.rs | 2 +- src/infrastructure/errors.rs | 10 +- src/infrastructure/file_system/tests.rs | 50 +++---- src/infrastructure/monitoring/logging/mod.rs | 4 +- .../logging/multi_log_file_writer.rs | 13 +- src/infrastructure/net/mod.rs | 5 +- src/infrastructure/net/trait.rs | 5 +- src/infrastructure/process/mod.rs | 4 +- src/infrastructure/process/tests.rs | 40 +++--- src/infrastructure/terminal.rs | 2 +- src/input/actor.rs | 8 +- src/kw/actor/actor_test.rs | 95 +++++++------- src/kw/actor/deploy_test.rs | 53 ++++---- src/kw/actor/mod.rs | 49 +++---- src/kw/argv.rs | 4 +- src/kw/history.rs | 18 +-- src/kw/models/readiness.rs | 8 +- src/kw/readiness.rs | 32 ++--- src/kw/remote.rs | 15 +-- src/kw/status.rs | 6 +- src/lore/application/actor.rs | 2 +- src/lore/application/service.rs | 4 +- src/lore/infrastructure/patchset_fetcher.rs | 23 ++-- src/lore/infrastructure/patchset_parser.rs | 8 +- src/lore/infrastructure/persistence.rs | 8 +- src/main.rs | 5 +- src/render/actor.rs | 8 +- src/terminal/actor.rs | 4 +- src/terminal/mod.rs | 6 +- src/ui/actor.rs | 14 +- src/ui/scene.rs | 2 +- 44 files changed, 492 insertions(+), 452 deletions(-) diff --git a/src/app/actions/mod.rs b/src/app/actions/mod.rs index a0fce97..364698c 100644 --- a/src/app/actions/mod.rs +++ b/src/app/actions/mod.rs @@ -9,8 +9,8 @@ use crate::{ lore::application::handle::LoreApiHandle, }; -use apply::{AppliedPatchset, ApplyPatchsetRequest}; -use reviewed_reply::{ReviewedReplyRequest, ReviewedReplyResult}; +use apply::{AppliedPatchset, ApplyPatchsetRequest, ApplyPatchsetService}; +use reviewed_reply::{ReviewedReplyRequest, ReviewedReplyResult, ReviewedReplyService}; pub(crate) struct PatchsetActionService<'a> { fs: &'a dyn FileSystemTrait, @@ -36,18 +36,13 @@ impl<'a> PatchsetActionService<'a> { request: &ApplyPatchsetRequest, config: &ConfigSnapshot, ) -> Result { - apply::ApplyPatchsetService::apply_patchset(request, self.fs, self.shell, config) + ApplyPatchsetService::apply_patchset(request, self.fs, self.shell, config) } pub(crate) async fn execute_reviewed_reply( &self, request: ReviewedReplyRequest, ) -> Result { - reviewed_reply::ReviewedReplyService::execute_reviewed_reply( - request, - self.lore_api, - self.shell, - ) - .await + ReviewedReplyService::execute_reviewed_reply(request, self.lore_api, self.shell).await } } diff --git a/src/app/actor.rs b/src/app/actor.rs index 8d17a0e..ccfec72 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -1,9 +1,9 @@ //! Central orchestration actor: owns [`App`] and drives the main render/input loop. //! //! Each frame: process system updates → project state to [`AppViewModel`] via -//! [`UiHandle`](crate::ui::handle::UiHandle) → draw through -//! [`TerminalHandle`](crate::terminal::handle::TerminalHandle) → await the next -//! [`InputEvent`](crate::input::event::InputEvent), a kw-status change, or a +//! `UiHandle` → draw through +//! `TerminalHandle` → await the next +//! `InputEvent`, a kw-status change, or a //! KwOps log-tail tick while a job is running on that screen. //! //! The actor stops when the input event channel closes (user quit) or when I/O @@ -30,6 +30,8 @@ use crate::{ terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, ui::handle::UiHandle, }; +use std::future::pending; +use tokio::time; /// Owns `App` state and drives the main application loop on a dedicated task. /// @@ -82,10 +84,8 @@ impl AppActor { let mut kw_status_rx = self.subscribe_kw_status().await; let mut loading = TerminalLoadingIndicator::new(self.terminal_handle.clone()); - let mut log_interval = tokio::time::interval_at( - tokio::time::Instant::now() + KW_OPS_LOG_TICK, - KW_OPS_LOG_TICK, - ); + let mut log_interval = + time::interval_at(time::Instant::now() + KW_OPS_LOG_TICK, KW_OPS_LOG_TICK); log_interval.set_missed_tick_behavior(MissedTickBehavior::Skip); let mut redraw = true; @@ -213,7 +213,7 @@ impl AppActor { Ok(()) => KwWatchEvent::Updated(rx.borrow_and_update().clone()), Err(_) => KwWatchEvent::Closed, }, - None => std::future::pending().await, + None => pending().await, } } @@ -399,6 +399,14 @@ mod tests { }; use super::*; + use crate::app::models::kw_ops::{DeployStartKind, KwOpsState}; + use crate::kw::models::{ + readiness::{ + BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, + TreeReadiness, + }, + remote::RemoteRefusal, + }; fn dummy_config_handle() -> ConfigHandle { let (config_tx, _config_rx) = mpsc::channel(1); @@ -567,8 +575,8 @@ mod tests { render.shutdown().await; } - fn sample_kw_ops() -> crate::app::models::kw_ops::KwOpsState { - crate::app::models::kw_ops::KwOpsState::new( + fn sample_kw_ops() -> KwOpsState { + KwOpsState::new( "title".to_string(), "mid".to_string(), "linux".to_string(), @@ -577,20 +585,20 @@ mod tests { "branch": "main" })) .unwrap(), - crate::kw::models::readiness::KwReadiness { - kw_binary: crate::kw::models::readiness::KwBinaryProbe { + KwReadiness { + kw_binary: KwBinaryProbe { available: true, version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::models::readiness::KwVersionCheck::Meets, + check: KwVersionCheck::Meets, }, - tree: crate::kw::models::readiness::TreeReadiness::Ready { + tree: TreeReadiness::Ready { arch: Some("x86_64".to_string()), }, output_dir: None, - deploy_alone: Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord), + deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), current_branch: Some("main".to_string()), - deploy_remote: Err(crate::kw::models::remote::RemoteRefusal::NoRemotesConfigured), - boot_once: crate::kw::models::readiness::BootOnceState::Unknown, + deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, }, ) } @@ -599,7 +607,7 @@ mod tests { async fn proceed_with_boot_once_acks_and_clears_pending() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::Deploy); + ops.pending_deploy = Some(DeployStartKind::Deploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); @@ -624,7 +632,7 @@ mod tests { async fn back_out_clears_pending_without_acknowledging() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::BuildThenDeploy); + ops.pending_deploy = Some(DeployStartKind::BuildThenDeploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); @@ -642,7 +650,7 @@ mod tests { fn closing_the_boot_once_popup_clears_pending() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::Deploy); + ops.pending_deploy = Some(DeployStartKind::Deploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::boot_once_warning()); @@ -656,14 +664,14 @@ mod tests { fn closing_the_quit_popup_does_not_touch_pending_deploy() { let mut app = minimal_app(); let mut ops = sample_kw_ops(); - ops.pending_deploy = Some(crate::app::models::kw_ops::DeployStartKind::Deploy); + ops.pending_deploy = Some(DeployStartKind::Deploy); app.state.kw.ops = Some(ops); app.state.popup = Some(AppPopup::quit_while_job_running()); AppActor::dismiss_open_popup(&mut app); assert!(app.state.popup.is_none()); assert_eq!( - Some(crate::app::models::kw_ops::DeployStartKind::Deploy), + Some(DeployStartKind::Deploy), app.state.kw.ops.as_ref().unwrap().pending_deploy ); } diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 21649b5..116ae8e 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -130,14 +130,16 @@ mod tests { }; use super::*; + use std::env; + use std::process; static TEST_SEQ: AtomicU64 = AtomicU64::new(0); fn unique_test_dir(prefix: &str) -> PathBuf { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let p = std::env::temp_dir().join(format!( + let p = env::temp_dir().join(format!( "patch-hub-edit-config-{prefix}-{}-{n}", - std::process::id() + process::id() )); fs::create_dir_all(&p).unwrap(); p diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index e991790..5bdea94 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -1,6 +1,7 @@ -use std::sync::Arc; +use std::{io, path::PathBuf, sync::Arc}; use color_eyre::Result; +use tokio::task; use crate::{ app::{ @@ -77,14 +78,13 @@ impl App { .status .as_ref() .and_then(|status| status.job.log_path()) - .map(std::path::PathBuf::from) + .map(PathBuf::from) else { return false; }; let fs = Arc::clone(&self.services.fs); let result = - tokio::task::spawn_blocking(move || fs.read_tail_to_string(&path, LOG_TAIL_MAX_BYTES)) - .await; + task::spawn_blocking(move || fs.read_tail_to_string(&path, LOG_TAIL_MAX_BYTES)).await; let text = match result { Ok(read) => Self::map_tail_read(read), Err(error) => format!("(could not read log: {error})"), @@ -335,7 +335,7 @@ impl App { match result { Ok(text) => text, Err(FileSystemError::IoError(error)) => { - if error.kind() == std::io::ErrorKind::NotFound { + if error.kind() == io::ErrorKind::NotFound { String::new() } else { format!("(could not read log: {error})") @@ -620,17 +620,14 @@ mod tests { #[test] fn missing_log_is_an_empty_tail_not_a_diagnostic() { - let missing = - FileSystemError::IoError(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")); + let missing = FileSystemError::IoError(io::Error::new(io::ErrorKind::NotFound, "missing")); assert_eq!("", App::map_tail_read(Err(missing))); } #[test] fn other_read_errors_become_a_panel_diagnostic() { - let error = FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "denied", - )); + let error = + FileSystemError::IoError(io::Error::new(io::ErrorKind::PermissionDenied, "denied")); let text = App::map_tail_read(Err(error)); assert!(text.contains("could not read log")); assert!(text.contains("denied")); diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index 9ff96d6..ac07c68 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -58,7 +58,9 @@ fn assert_info_popup(popup: Option<&AppPopup>, expected_title: &str, fragment: & mod unix { use std::{ - path::PathBuf, + env, fs, io, + path::{Path, PathBuf}, + process, sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, @@ -66,6 +68,9 @@ mod unix { time::Duration, }; + use tokio::{sync::mpsc, time}; + + use crate::app::handle::AppHandle; use crate::{ app::{actor::AppActor, screens::CurrentScreen, App}, config::{ConfigSnapshot, ConfigState}, @@ -91,7 +96,7 @@ mod unix { }, ui::{ actor::UiActor, - scene::{UiBody, UiScene}, + scene::{KwOpsScene, PopupScene, UiBody, UiScene}, }, }; @@ -122,7 +127,7 @@ mod unix { assert!(!ops.head_unreadable); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -147,7 +152,7 @@ mod unix { ); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -188,7 +193,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -224,7 +229,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -251,7 +256,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -290,7 +295,7 @@ mod unix { .is_some_and(|ops| ops.log_tail.contains("from mock"))); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -311,7 +316,7 @@ mod unix { assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -329,7 +334,7 @@ mod unix { assert!(app.state.lore.details.is_some()); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -355,7 +360,7 @@ mod unix { assert_eq!(BootOnceState::Off, ops.readiness.boot_once); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -387,7 +392,7 @@ mod unix { assert!(process.spawned().is_empty()); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -415,7 +420,7 @@ mod unix { assert!(process.spawned().is_empty()); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -460,7 +465,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -486,12 +491,12 @@ mod unix { assert_eq!(vec!["build"], process.spawned()[0].args); process.last_child().finish(0); - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { if process.spawned().len() >= 2 { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -503,7 +508,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -550,7 +555,7 @@ mod unix { assert_eq!(Some("feature".to_string()), ops.readiness.current_branch); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -583,7 +588,7 @@ mod unix { drop(event_tx); handle.run_until_done().await.unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -619,7 +624,7 @@ mod unix { drop(event_tx); handle.run_until_done().await.unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -653,9 +658,9 @@ mod unix { wait_for_nav(&scenes, |text| text.contains("kw: building feature")).await; let draws_on_details = scene_count(&scenes); - tokio::time::sleep(Duration::from_millis(800)).await; + time::sleep(Duration::from_millis(800)).await; process.last_child().write_log(b"ld: linking vmlinux\n"); - tokio::time::sleep(Duration::from_millis(800)).await; + time::sleep(Duration::from_millis(800)).await; assert_eq!( draws_on_details, scene_count(&scenes), @@ -663,12 +668,12 @@ mod unix { ); event_tx.send(InputEvent::ToggleBookmark).await.unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { if scene_count(&scenes) > draws_on_details { break; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -690,7 +695,7 @@ mod unix { .await; let draws_after_success = scene_count(&scenes); - tokio::time::sleep(Duration::from_millis(800)).await; + time::sleep(Duration::from_millis(800)).await; assert_eq!( draws_after_success, scene_count(&scenes), @@ -700,10 +705,10 @@ mod unix { drop(event_tx); handle.run_until_done().await.unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } - fn app_with_details_and_kw(log_dir: &std::path::Path, kw_shell: MockShellTrait) -> App { + fn app_with_details_and_kw(log_dir: &Path, kw_shell: MockShellTrait) -> App { app_with_details_and_kw_process( log_dir, kw_shell, @@ -713,7 +718,7 @@ mod unix { } fn app_with_details_and_kw_process( - log_dir: &std::path::Path, + log_dir: &Path, kw_shell: MockShellTrait, process: Arc, app_fs: Arc, @@ -729,7 +734,7 @@ mod unix { } fn app_with_kw( - log_dir: &std::path::Path, + log_dir: &Path, kw_shell: MockShellTrait, process: Arc, app_fs: Arc, @@ -837,8 +842,8 @@ mod unix { } else if path.ends_with("deploy.config") { Ok(deploy_config.clone()) } else { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) } @@ -847,17 +852,14 @@ mod unix { if path.ends_with("arch/x86/boot") { Ok(vec![PathBuf::from("/kernel/arch/x86/boot/bzImage")]) } else { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) } }); - fs.expect_metadata().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::other( - "no metadata", - ))) - }); + fs.expect_metadata() + .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); fs.expect_create_dir_all().returning(|_| Ok(())); fs } @@ -887,14 +889,14 @@ mod unix { .returning(|path| !path.ends_with(".kw/env.current")); fs.expect_exists().returning(|_| true); fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) }); fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) }); @@ -931,13 +933,13 @@ mod unix { fn kw_log_dir(test_name: &str) -> PathBuf { let n = LOG_DIR_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!( + let dir = env::temp_dir().join(format!( "patch-hub-app-kw-ops-{}-{}-{n}", test_name, - std::process::id() + process::id() )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); dir } @@ -945,8 +947,8 @@ mod unix { app: App, ) -> ( Arc>>, - tokio::sync::mpsc::Sender, - crate::app::handle::AppHandle, + mpsc::Sender, + AppHandle, ) { let scenes = Arc::new(Mutex::new(Vec::new())); let scenes_for_draw = Arc::clone(&scenes); @@ -964,15 +966,15 @@ mod unix { let terminal_handle = TerminalActor::spawn(Box::new(session)); let ui_handle = UiActor::spawn(); - let (event_tx, event_rx) = tokio::sync::mpsc::channel::(8); - let (input_tx, _input_rx) = tokio::sync::mpsc::channel::(1); + let (event_tx, event_rx) = mpsc::channel::(8); + let (input_tx, _input_rx) = mpsc::channel::(1); let input_handle = InputHandle::new(input_tx); let handle = AppActor::spawn(app, terminal_handle, ui_handle, input_handle, event_rx); (scenes, event_tx, handle) } async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str) -> bool) { - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { if scenes .lock() @@ -982,7 +984,7 @@ mod unix { { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -991,9 +993,9 @@ mod unix { async fn wait_for_kw_ops( scenes: &Arc>>, - predicate: impl Fn(&crate::ui::scene::KwOpsScene) -> bool, + predicate: impl Fn(&KwOpsScene) -> bool, ) { - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { let matches = scenes.lock().unwrap().last().is_some_and( |scene| matches!(&scene.body, UiBody::KwOps(ops) if predicate(ops)), @@ -1001,7 +1003,7 @@ mod unix { if matches { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -1010,9 +1012,9 @@ mod unix { async fn wait_for_latest_popup( scenes: &Arc>>, - predicate: impl Fn(Option<&crate::ui::scene::PopupScene>) -> bool, + predicate: impl Fn(Option<&PopupScene>) -> bool, ) { - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { let matches = scenes .lock() @@ -1022,7 +1024,7 @@ mod unix { if matches { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -1030,7 +1032,7 @@ mod unix { } async fn wait_for_details(scenes: &Arc>>) { - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { if scenes .lock() @@ -1040,7 +1042,7 @@ mod unix { { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index 13c3281..0ac1e0f 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -7,6 +7,10 @@ use std::{ time::Duration, }; +use crate::app::handle::AppHandle; +use crate::app::App; +use crate::kw::handle::KwHandle; +use crate::ui::scene::{PopupBody, PopupScene}; use crate::{ app::actor::AppActor, config::{ConfigSnapshot, ConfigState}, @@ -27,6 +31,13 @@ use super::helpers::{ app_harness::{dummy_config_handle, dummy_render_handle}, lore::{lore_handle_with_persistence, sample_mailing_list}, }; +use std::env; +use std::fs; +use std::io; +use std::path::Path; +use std::process; +use tokio::sync::mpsc; +use tokio::time; const KERNEL_TREE_PATH: &str = "/kernel"; const BUILD_BRANCH: &str = "patchset-2026-08-20-15-00-00"; @@ -46,7 +57,7 @@ async fn first_frame_shows_a_job_that_started_before_attach() { drop(event_tx); handle.run_until_done().await.unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -74,7 +85,7 @@ async fn status_change_redraws_without_input() { drop(event_tx); handle.run_until_done().await.unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -87,9 +98,9 @@ async fn closed_watch_does_not_busy_loop_and_input_still_works() { kw.shutdown().await; // Give the actor a moment to observe the closed watch and redraw once. - tokio::time::sleep(Duration::from_millis(50)).await; + time::sleep(Duration::from_millis(50)).await; let draws_after_close = scene_count(&scenes); - tokio::time::sleep(Duration::from_millis(150)).await; + time::sleep(Duration::from_millis(150)).await; assert_eq!( draws_after_close, scene_count(&scenes), @@ -97,12 +108,12 @@ async fn closed_watch_does_not_busy_loop_and_input_still_works() { ); event_tx.send(InputEvent::NavigateDown).await.unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { if scene_count(&scenes) > draws_after_close { break; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -110,7 +121,7 @@ async fn closed_watch_does_not_busy_loop_and_input_still_works() { drop(event_tx); handle.run_until_done().await.unwrap(); - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -121,13 +132,13 @@ async fn quit_with_no_job_exits_without_confirm() { wait_for_nav(&scenes, |_| true).await; event_tx.send(InputEvent::Quit).await.unwrap(); - tokio::time::timeout(Duration::from_secs(5), handle.run_until_done()) + time::timeout(Duration::from_secs(5), handle.run_until_done()) .await .expect("quit with no running job must exit without a confirm popup") .unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -147,7 +158,7 @@ async fn quit_while_job_running_opens_confirm_and_wait_keeps_app_alive() { let latest = scenes.lock().unwrap().last().cloned().unwrap(); let popup = latest.popup.expect("confirm popup"); match popup.body { - crate::ui::scene::PopupBody::Confirm { + PopupBody::Confirm { options, selected, .. } => { assert_eq!( @@ -168,7 +179,7 @@ async fn quit_while_job_running_opens_confirm_and_wait_keeps_app_alive() { drop(event_tx); handle.run_until_done().await.unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -190,7 +201,7 @@ async fn quit_confirm_esc_is_wait() { drop(event_tx); handle.run_until_done().await.unwrap(); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -208,27 +219,27 @@ async fn cancel_and_quit_requests_cancellation() { event_tx.send(InputEvent::ConfirmPopup).await.unwrap(); handle.run_until_done().await.unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { if process.last_child().was_killed() { break; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await .expect("cancel-and-quit must request kw cancellation"); drop(event_tx); kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } fn spawn_app_actor( - app: crate::app::App, + app: App, ) -> ( Arc>>, - tokio::sync::mpsc::Sender, - crate::app::handle::AppHandle, + mpsc::Sender, + AppHandle, ) { let scenes = Arc::new(Mutex::new(Vec::new())); let scenes_for_draw = Arc::clone(&scenes); @@ -246,20 +257,14 @@ fn spawn_app_actor( let terminal_handle = TerminalActor::spawn(Box::new(session)); let ui_handle = UiActor::spawn(); - let (event_tx, event_rx) = tokio::sync::mpsc::channel::(8); - let (input_tx, _input_rx) = tokio::sync::mpsc::channel::(1); + let (event_tx, event_rx) = mpsc::channel::(8); + let (input_tx, _input_rx) = mpsc::channel::(1); let input_handle = InputHandle::new(input_tx); let handle = AppActor::spawn(app, terminal_handle, ui_handle, input_handle, event_rx); (scenes, event_tx, handle) } -fn app_with_kw( - log_dir: &std::path::Path, -) -> ( - crate::app::App, - crate::kw::handle::KwHandle, - Arc, -) { +fn app_with_kw(log_dir: &Path) -> (App, KwHandle, Arc) { let process = Arc::new(FakeProcess::new()); let mut history = MockKwHistoryStore::new(); history @@ -274,7 +279,7 @@ fn app_with_kw( Arc::new(kw_actor_env()), log_dir.to_path_buf(), ); - let app = crate::app::App::new( + let app = App::new( apply_config(), dummy_config_handle(), BootstrapLoreData { @@ -294,7 +299,7 @@ fn app_with_kw( } async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str) -> bool) { - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { if scenes .lock() @@ -304,7 +309,7 @@ async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -313,9 +318,9 @@ async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str async fn wait_for_latest_popup( scenes: &Arc>>, - predicate: impl Fn(Option<&crate::ui::scene::PopupScene>) -> bool, + predicate: impl Fn(Option<&PopupScene>) -> bool, ) { - tokio::time::timeout(Duration::from_secs(5), async { + time::timeout(Duration::from_secs(5), async { loop { let matches = scenes .lock() @@ -325,7 +330,7 @@ async fn wait_for_latest_popup( if matches { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await @@ -347,13 +352,13 @@ fn nav_text(scene: &UiScene) -> String { fn kw_log_dir(test_name: &str) -> PathBuf { let n = LOG_DIR_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!( + let dir = env::temp_dir().join(format!( "patch-hub-app-kw-status-{}-{}-{n}", test_name, - std::process::id() + process::id() )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); dir } @@ -410,14 +415,14 @@ fn kw_actor_fs() -> MockFileSystemTrait { .returning(|path| !path.ends_with(".kw/env.current")); fs.expect_exists().returning(|_| true); fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) }); fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) }); diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index c6747aa..8d30a14 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -37,6 +37,12 @@ use super::helpers::{ }, render::sample_rendered_preview, }; +use std::env; +use std::fs; +use std::io; +use std::process; +use std::time::Duration; +use tokio::time; const KERNEL_TREE_PATH: &str = "/kernel"; const BASE_BRANCH: &str = "main"; @@ -203,7 +209,7 @@ async fn apply_success_with_history_write_failure_keeps_success_popup() { kw_history .expect_record_apply() .times(1) - .returning(|_| Err(FileSystemError::IoError(std::io::Error::other("disk full")))); + .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); let mut app = app_with_details( clean_fs(), shell, @@ -292,7 +298,7 @@ async fn apply_is_blocked_while_a_kw_job_runs() { assert!(calls.lock().unwrap().is_empty()); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -343,7 +349,7 @@ async fn apply_is_allowed_again_after_the_job_finishes() { // Once the job finishes, the same apply goes through. process.last_child().finish(0); let mut watch = kw.watch_status().await.unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(10), async { + time::timeout(Duration::from_secs(10), async { loop { if matches!(watch.borrow().job, KwJobStatus::Succeeded { .. }) { break; @@ -372,7 +378,7 @@ async fn apply_is_allowed_again_after_the_job_finishes() { assert_eq!(6, calls.lock().unwrap().len()); shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -708,14 +714,14 @@ fn kw_actor_fs() -> MockFileSystemTrait { .returning(|path| !path.ends_with(".kw/env.current")); fs.expect_exists().returning(|_| true); fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) }); fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, "missing", ))) }); @@ -732,13 +738,13 @@ fn kw_actor_env() -> MockEnvTrait { /// A real, unique directory: FakeProcess creates the job's log file on /// spawn, even though the fs trait is mocked. fn kw_log_dir(test_name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( + let dir = env::temp_dir().join(format!( "patch-hub-app-kw-logs-{}-{}", test_name, - std::process::id() + process::id() )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); dir } diff --git a/src/app/mod.rs b/src/app/mod.rs index 60cb9ce..4f0491a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,12 +1,12 @@ //! Application orchestration: state, screen flows, view-model projection, and the -//! central [`AppActor`](crate::app::actor::AppActor) run loop. +//! central `AppActor` run loop. //! //! [`App`] holds [`AppState`] (navigation, lore UI state, user data, config //! snapshot) and [`AppServices`] (typed handles to -//! [`LoreApiHandle`](crate::lore::application::handle::LoreApiHandle), -//! [`RenderHandle`](crate::render::handle::RenderHandle), plus injected +//! `LoreApiHandle`, +//! `RenderHandle`, plus injected //! infrastructure traits). Screen-specific input is dispatched from -//! [`AppActor`](crate::app::actor::AppActor) into [`crate::app::flows`]; +//! `AppActor` into [`app::flows`]; //! presentation data crosses the UI boundary only through [`AppViewModel`] via //! [`App::present`]. pub(crate) mod actions; @@ -33,7 +33,7 @@ use color_eyre::{ }; use tracing::{debug, event, info, warn, Level}; -use std::sync::Arc; +use std::{collections::HashSet, sync::Arc}; use chrono::{SecondsFormat, Utc}; @@ -62,7 +62,7 @@ use crate::{ }, render::{handle::RenderHandle, RenderPatchsetRequest}, }; -use models::view_model::AppViewModel; +use models::{popup::AppPopup, view_model::AppViewModel}; use screens::{ bookmarked::BookmarkedPatchsetsState, details_actions::{PatchsetAction, PatchsetDetailsState}, @@ -491,11 +491,11 @@ impl App { /// The popup blocking an apply while a kw job runs, if a job is in /// fact running. An unreachable actor cannot be running a job, so a /// status-query failure lets the apply proceed. - async fn kw_job_running_popup(&self) -> Option { + async fn kw_job_running_popup(&self) -> Option { let kw = self.services.kw.as_ref()?; match kw.get_status().await { Ok(snapshot) => match snapshot.job { - KwJobStatus::Running { .. } => Some(models::popup::AppPopup::info( + KwJobStatus::Running { .. } => Some(AppPopup::info( "Patchset Apply Blocked", " A kw job is running on the kernel tree.\n\nApplying a patchset now would rewrite the branch the job is building under it.\n\nWait for the job to finish, then apply again.", )), @@ -510,10 +510,7 @@ impl App { /// Runs the git-am apply and maps the outcome to the result popup, /// recording the apply in the kw history on success. - async fn apply_patchset_popup( - &self, - details: &PatchsetDetailsState, - ) -> models::popup::AppPopup { + async fn apply_patchset_popup(&self, details: &PatchsetDetailsState) -> AppPopup { let request = Self::build_apply_patchset_request(details); let action_service = PatchsetActionService::new( &*self.services.fs, @@ -565,9 +562,9 @@ impl App { } } }; - models::popup::AppPopup::info("Patchset Apply Success", popup_body) + AppPopup::info("Patchset Apply Success", popup_body) } - Err(msg) => models::popup::AppPopup::info("Patchset Apply Fail", msg), + Err(msg) => AppPopup::info("Patchset Apply Fail", msg), } } @@ -580,7 +577,7 @@ impl App { fn build_reviewed_reply_request( details: &PatchsetDetailsState, - successful_indexes: std::collections::HashSet, + successful_indexes: HashSet, git_send_email_options: String, ) -> ReviewedReplyRequest { ReviewedReplyRequest { diff --git a/src/app/models/kw_ops.rs b/src/app/models/kw_ops.rs index 5000e17..4389826 100644 --- a/src/app/models/kw_ops.rs +++ b/src/app/models/kw_ops.rs @@ -15,7 +15,7 @@ pub enum DeployStartKind { BuildThenDeploy, } -/// Form state on the KwOps screen. Job status lives in [`crate::app::state::KwUiState::status`]. +/// Form state on the KwOps screen. Job status lives in [`KwUiState::status`]. #[derive(Clone, Debug)] pub struct KwOpsState { pub patchset_title: String, diff --git a/src/app/models/view_model.rs b/src/app/models/view_model.rs index 86a74a0..f573946 100644 --- a/src/app/models/view_model.rs +++ b/src/app/models/view_model.rs @@ -184,7 +184,7 @@ pub enum ScreenViewModel { /// Owned, typed projection of [`AppState`] for one TUI frame. /// -/// Built from [`AppState`](crate::app::state::AppState); consumed by `UiCore::build_scene`. +/// Built from `AppState`; consumed by `UiCore::build_scene`. #[derive(Clone, Debug)] pub struct AppViewModel { pub screen: ScreenViewModel, diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 28119ea..ab50239 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -1,7 +1,7 @@ //! Owned, typed presentation projections. //! -//! [`AppViewModel`] is built from [`super::state::AppState`] and handed off -//! through [`super::App::present`]. +//! [`AppViewModel`] is built from [`state::AppState`] and handed off +//! through [`App::present`]. //! //! These types sit at the *application* layer: they represent what `App` knows //! about the presentation before `UiCore` translates them into paint-ready @@ -17,7 +17,7 @@ use super::{ use crate::kw::{ argv, models::{ - readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, + readiness::{BootOnceState, DeployAloneRefusal, KwBinaryProbe, TreeReadiness}, remote::{KwRemote, RemoteRefusal}, }, status::{KwJobStatus, KwPhase, KwStatusSnapshot}, @@ -32,7 +32,7 @@ use crate::app::models::view_model::{ /// Builds the full view model the UI renders from the current app state. /// -/// [`super::App::present`] performs this conversion. +/// [`App::present`] performs this conversion. impl From<&AppState> for AppViewModel { fn from(state: &AppState) -> Self { let screen = ScreenViewModel::from(state); @@ -539,7 +539,7 @@ impl ViewModelFormatService { } } - fn format_kw_binary(probe: &crate::kw::models::readiness::KwBinaryProbe) -> String { + fn format_kw_binary(probe: &KwBinaryProbe) -> String { if !probe.available { return "not on PATH".to_string(); } @@ -670,6 +670,10 @@ impl From<&AppPopup> for PopupViewModel { mod tests { use std::{collections::HashMap, path::PathBuf}; + use crate::{ + app::{models::kw_ops::KwOpsState, screens::edit_config::EditConfigState}, + kw::models::readiness::{KwReadiness, KwVersionCheck}, + }; use crate::{ app::{ screens::{bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState}, @@ -764,7 +768,7 @@ mod tests { .unwrap(), ); state.config = ConfigSnapshot::from(&config); - let mut edit = crate::app::screens::edit_config::EditConfigState::new(&state.config); + let mut edit = EditConfigState::new(&state.config); while edit.highlighted() != 11 { edit.highlight_next(); } @@ -800,7 +804,7 @@ mod tests { fn kw_ops_command_strips_reserved_extras() { let mut state = app_state_with_kw(None); state.navigation.current_screen = CurrentScreen::KwOps; - let mut ops = crate::app::models::kw_ops::KwOpsState::new( + let mut ops = KwOpsState::new( "[PATCH] test".to_string(), "http://lore.example/123".to_string(), "linux".to_string(), @@ -809,20 +813,20 @@ mod tests { "branch": "main" })) .unwrap(), - crate::kw::models::readiness::KwReadiness { - kw_binary: crate::kw::models::readiness::KwBinaryProbe { + KwReadiness { + kw_binary: KwBinaryProbe { available: true, version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::models::readiness::KwVersionCheck::Meets, + check: KwVersionCheck::Meets, }, - tree: crate::kw::models::readiness::TreeReadiness::Ready { + tree: TreeReadiness::Ready { arch: Some("x86_64".to_string()), }, output_dir: None, - deploy_alone: Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord), + deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), current_branch: Some("feature".to_string()), - deploy_remote: Err(crate::kw::models::remote::RemoteRefusal::NoRemotesConfigured), - boot_once: crate::kw::models::readiness::BootOnceState::Unknown, + deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, }, ); ops.extra_args = "--verbose --clean --from-sha abc --doc".to_string(); @@ -844,8 +848,8 @@ mod tests { assert_eq!("unavailable (no remotes configured)", vm.build_deploy_label); } - fn sample_kw_ops(branch: Option<&str>) -> crate::app::models::kw_ops::KwOpsState { - crate::app::models::kw_ops::KwOpsState::new( + fn sample_kw_ops(branch: Option<&str>) -> KwOpsState { + KwOpsState::new( "[PATCH] test".to_string(), "http://lore.example/123".to_string(), "linux".to_string(), @@ -854,20 +858,20 @@ mod tests { "branch": "main" })) .unwrap(), - crate::kw::models::readiness::KwReadiness { - kw_binary: crate::kw::models::readiness::KwBinaryProbe { + KwReadiness { + kw_binary: KwBinaryProbe { available: true, version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::models::readiness::KwVersionCheck::Meets, + check: KwVersionCheck::Meets, }, - tree: crate::kw::models::readiness::TreeReadiness::Ready { + tree: TreeReadiness::Ready { arch: Some("x86_64".to_string()), }, output_dir: None, - deploy_alone: Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord), + deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), current_branch: branch.map(str::to_string), - deploy_remote: Err(crate::kw::models::remote::RemoteRefusal::NoRemotesConfigured), - boot_once: crate::kw::models::readiness::BootOnceState::Unknown, + deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, }, ) } @@ -945,8 +949,8 @@ mod tests { ); } - fn sample_remote() -> crate::kw::models::remote::KwRemote { - crate::kw::models::remote::KwRemote { + fn sample_remote() -> KwRemote { + KwRemote { name: "dut".to_string(), hostname: "box".to_string(), port: 22, @@ -960,9 +964,8 @@ mod tests { state.navigation.current_screen = CurrentScreen::KwOps; let mut ops = sample_kw_ops(Some("feature")); ops.readiness.deploy_remote = Ok(sample_remote()); - ops.readiness.deploy_alone = - Err(crate::kw::models::readiness::DeployAloneRefusal::NoBuildRecord); - ops.readiness.boot_once = crate::kw::models::readiness::BootOnceState::On; + ops.readiness.deploy_alone = Err(DeployAloneRefusal::NoBuildRecord); + ops.readiness.boot_once = BootOnceState::On; state.kw.ops = Some(ops); let ScreenViewModel::KwOps(vm) = AppViewModel::from(&state).screen else { @@ -985,7 +988,7 @@ mod tests { let mut ops = sample_kw_ops(Some("feature")); ops.readiness.deploy_remote = Ok(sample_remote()); ops.readiness.deploy_alone = Ok(()); - ops.readiness.boot_once = crate::kw::models::readiness::BootOnceState::Off; + ops.readiness.boot_once = BootOnceState::Off; ops.extra_args = "--verbose --local --ccache".to_string(); state.kw.ops = Some(ops); @@ -1008,7 +1011,7 @@ mod tests { let mut ops = sample_kw_ops(Some("feature")); ops.readiness.deploy_remote = Ok(sample_remote()); ops.readiness.deploy_alone = Ok(()); - ops.readiness.boot_once = crate::kw::models::readiness::BootOnceState::Unknown; + ops.readiness.boot_once = BootOnceState::Unknown; ops.boot_once_acknowledged = true; state.kw.ops = Some(ops); diff --git a/src/config/actor.rs b/src/config/actor.rs index c0b1f3a..a899f6f 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -1,8 +1,8 @@ //! Configuration actor: serializes access to mutable config state. //! -//! All config reads and edits go through [`ConfigHandle`](crate::config::ConfigHandle) -//! as typed request/reply messages. The actor owns [`ConfigState`](crate::config::ConfigState) -//! and persists successful updates through [`JsonConfigRepository`](crate::config::JsonConfigRepository). +//! All config reads and edits go through `ConfigHandle` +//! as typed request/reply messages. The actor owns `ConfigState` +//! and persists successful updates through `JsonConfigRepository`. use std::ops::ControlFlow; use tokio::{spawn, sync::mpsc}; @@ -125,12 +125,14 @@ mod tests { }; use super::*; + use std::env; + use std::process; static TEST_SEQ: AtomicU64 = AtomicU64::new(0); fn unique_test_dir(prefix: &str) -> PathBuf { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let p = std::env::temp_dir().join(format!("patch-hub-{prefix}-{}-{n}", std::process::id())); + let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{n}", process::id())); fs::create_dir_all(&p).unwrap(); p } diff --git a/src/config/tests.rs b/src/config/tests.rs index 6c8bf10..4e153ea 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -19,6 +19,8 @@ use crate::infrastructure::{ env::{EnvTrait, MockEnvTrait}, file_system::OsFileSystem, }; +use std::env; +use std::process; static TEST_SEQ: AtomicU64 = AtomicU64::new(0); @@ -49,7 +51,7 @@ fn state_with_trees(env: &dyn EnvTrait) -> ConfigState { fn unique_test_dir(prefix: &str) -> PathBuf { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let p = std::env::temp_dir().join(format!("patch-hub-{prefix}-{}-{}", std::process::id(), n)); + let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{}", process::id(), n)); fs::create_dir_all(&p).unwrap(); p } diff --git a/src/config/update.rs b/src/config/update.rs index 2ee3b47..9dc1cb2 100644 --- a/src/config/update.rs +++ b/src/config/update.rs @@ -18,7 +18,7 @@ pub struct ConfigUpdateDraft { pub target_kernel_tree: Option, } -/// Parsed and validated update ready to merge into [`crate::config::ConfigState`](super::state::ConfigState). +/// Parsed and validated update ready to merge into `config::ConfigState`. #[derive(Debug, Default, Clone)] pub struct ValidatedConfigUpdate { pub page_size: Option, diff --git a/src/infrastructure/errors.rs b/src/infrastructure/errors.rs index 257e32b..d28b413 100644 --- a/src/infrastructure/errors.rs +++ b/src/infrastructure/errors.rs @@ -53,11 +53,11 @@ impl TerminalRestoreGuard { /// report construction, including errors the application handles and keeps /// running after, so it must not restore the terminal. /// -/// `main` returns [`color_eyre::eyre::Result`], and the runtime prints a fatal +/// `main` returns [`eyre::Result`], and the runtime prints a fatal /// report after `main` returns. [`TerminalRestoreGuard::restore_once`] runs on /// that path first. /// Normal shutdown restores through -/// [`crate::terminal::handle::TerminalHandle::shutdown`]. +/// [`TerminalHandle::shutdown`]. pub fn install_hooks() -> Result<()> { let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); @@ -82,10 +82,12 @@ mod tests { use std::sync::atomic::AtomicUsize; use super::*; + use color_eyre::eyre; + use std::panic::catch_unwind; #[test] fn test_error_hook_works() { - let result: color_eyre::Result<()> = Err(color_eyre::eyre::eyre!("Test error")); + let result: color_eyre::Result<()> = Err(eyre::eyre!("Test error")); // We can't directly test the hook's formatting, but we can verify // that handling an error doesn't cause unexpected panics @@ -99,7 +101,7 @@ mod tests { #[test] fn test_panic_hook() { - let result = std::panic::catch_unwind(|| std::panic!("Test panic")); + let result = catch_unwind(|| std::panic!("Test panic")); assert!(result.is_err()); } diff --git a/src/infrastructure/file_system/tests.rs b/src/infrastructure/file_system/tests.rs index 196a0be..e1a464e 100644 --- a/src/infrastructure/file_system/tests.rs +++ b/src/infrastructure/file_system/tests.rs @@ -1,17 +1,17 @@ use std::path::{Path, PathBuf}; use super::{FileSystemTrait, OsFileSystem}; +use std::env; +use std::fs; +use std::process; struct TempDir(PathBuf); impl TempDir { fn new(test_name: &str) -> Self { - let dir = std::env::temp_dir().join(format!( - "patch_hub_fs_test_{}_{test_name}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let dir = env::temp_dir().join(format!("patch_hub_fs_test_{}_{test_name}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); Self(dir) } @@ -22,7 +22,7 @@ impl TempDir { impl Drop for TempDir { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); + let _ = fs::remove_dir_all(&self.0); } } @@ -30,7 +30,7 @@ impl Drop for TempDir { fn read_to_string_returns_file_contents() { let dir = TempDir::new("read_to_string"); let file_path = dir.path().join("test.txt"); - std::fs::write(&file_path, "hello world").unwrap(); + fs::write(&file_path, "hello world").unwrap(); let fs = OsFileSystem; let contents = fs.read_to_string(&file_path).unwrap(); @@ -52,7 +52,7 @@ fn write_creates_file_with_contents() { let fs = OsFileSystem; fs.write(&file_path, b"test data").unwrap(); - let contents = std::fs::read_to_string(&file_path).unwrap(); + let contents = fs::read_to_string(&file_path).unwrap(); assert_eq!(contents, "test data"); } @@ -71,7 +71,7 @@ fn create_dir_all_creates_nested_directories() { fn exists_returns_true_for_existing_path() { let dir = TempDir::new("exists"); let file_path = dir.path().join("exists_test.txt"); - std::fs::write(&file_path, "").unwrap(); + fs::write(&file_path, "").unwrap(); let fs = OsFileSystem; assert!(fs.exists(&file_path)); @@ -83,7 +83,7 @@ fn exists_returns_true_for_existing_path() { fn is_file_distinguishes_files_from_dirs() { let dir = TempDir::new("is_file"); let file_path = dir.path().join("a_file.txt"); - std::fs::write(&file_path, "").unwrap(); + fs::write(&file_path, "").unwrap(); let fs = OsFileSystem; assert!(fs.is_file(&file_path)); @@ -94,7 +94,7 @@ fn is_file_distinguishes_files_from_dirs() { fn is_dir_distinguishes_dirs_from_files() { let dir = TempDir::new("is_dir"); let file_path = dir.path().join("a_file.txt"); - std::fs::write(&file_path, "").unwrap(); + fs::write(&file_path, "").unwrap(); let fs = OsFileSystem; assert!(fs.is_dir(dir.path())); @@ -104,10 +104,10 @@ fn is_dir_distinguishes_dirs_from_files() { #[test] fn read_dir_lists_immediate_children_sorted() { let dir = TempDir::new("read_dir"); - std::fs::write(dir.path().join("b.txt"), "").unwrap(); - std::fs::create_dir(dir.path().join("a_sub")).unwrap(); - std::fs::create_dir(dir.path().join("a_sub/nested")).unwrap(); - std::fs::write(dir.path().join("c.txt"), "").unwrap(); + fs::write(dir.path().join("b.txt"), "").unwrap(); + fs::create_dir(dir.path().join("a_sub")).unwrap(); + fs::create_dir(dir.path().join("a_sub/nested")).unwrap(); + fs::write(dir.path().join("c.txt"), "").unwrap(); let fs = OsFileSystem; let entries = fs.read_dir(dir.path()).unwrap(); @@ -134,13 +134,13 @@ fn rename_moves_file() { let dir = TempDir::new("rename"); let src = dir.path().join("src.txt"); let dst = dir.path().join("dst.txt"); - std::fs::write(&src, "rename me").unwrap(); + fs::write(&src, "rename me").unwrap(); let fs = OsFileSystem; fs.rename(&src, &dst).unwrap(); assert!(!src.exists()); - assert_eq!(std::fs::read_to_string(&dst).unwrap(), "rename me"); + assert_eq!(fs::read_to_string(&dst).unwrap(), "rename me"); } #[test] @@ -155,7 +155,7 @@ fn create_writer_writes_to_file() { writer.write_all(b"via writer").unwrap(); drop(writer); - let contents = std::fs::read_to_string(&file_path).unwrap(); + let contents = fs::read_to_string(&file_path).unwrap(); assert_eq!(contents, "via writer"); } @@ -165,7 +165,7 @@ fn open_bufreader_reads_file() { let dir = TempDir::new("open_bufreader"); let file_path = dir.path().join("bufreader_test.txt"); - std::fs::write(&file_path, "line1\nline2\n").unwrap(); + fs::write(&file_path, "line1\nline2\n").unwrap(); let fs = OsFileSystem; let reader = fs.open_bufreader(&file_path).unwrap(); @@ -178,7 +178,7 @@ fn open_bufreader_reads_file() { fn metadata_returns_file_metadata() { let dir = TempDir::new("metadata"); let file_path = dir.path().join("meta_test.txt"); - std::fs::write(&file_path, "some content").unwrap(); + fs::write(&file_path, "some content").unwrap(); let fs = OsFileSystem; let meta = fs.metadata(&file_path).unwrap(); @@ -197,7 +197,7 @@ fn metadata_returns_error_for_missing_path() { fn read_tail_returns_the_whole_file_when_it_fits() { let dir = TempDir::new("read_tail_fits"); let file_path = dir.path().join("log.txt"); - std::fs::write(&file_path, "line1\nline2\n").unwrap(); + fs::write(&file_path, "line1\nline2\n").unwrap(); let fs = OsFileSystem; assert_eq!( @@ -211,7 +211,7 @@ fn read_tail_drops_a_partial_first_line_when_seeking() { let dir = TempDir::new("read_tail_partial"); let file_path = dir.path().join("log.txt"); // 14 bytes; an 8-byte window starts mid-"BBBB". - std::fs::write(&file_path, "AAAA\nBBBB\nCCCC").unwrap(); + fs::write(&file_path, "AAAA\nBBBB\nCCCC").unwrap(); let fs = OsFileSystem; assert_eq!("CCCC", fs.read_tail_to_string(&file_path, 8).unwrap()); @@ -221,7 +221,7 @@ fn read_tail_drops_a_partial_first_line_when_seeking() { fn read_tail_keeps_a_window_with_no_newline() { let dir = TempDir::new("read_tail_nonewline"); let file_path = dir.path().join("log.txt"); - std::fs::write(&file_path, "abcdefghijklmnopqrst").unwrap(); + fs::write(&file_path, "abcdefghijklmnopqrst").unwrap(); let fs = OsFileSystem; assert_eq!("mnopqrst", fs.read_tail_to_string(&file_path, 8).unwrap()); @@ -231,7 +231,7 @@ fn read_tail_keeps_a_window_with_no_newline() { fn read_tail_decodes_invalid_utf8_lossily() { let dir = TempDir::new("read_tail_utf8"); let file_path = dir.path().join("log.txt"); - std::fs::write(&file_path, b"ok\n\xff\xfeworld").unwrap(); + fs::write(&file_path, b"ok\n\xff\xfeworld").unwrap(); let fs = OsFileSystem; let text = fs.read_tail_to_string(&file_path, 64).unwrap(); diff --git a/src/infrastructure/monitoring/logging/mod.rs b/src/infrastructure/monitoring/logging/mod.rs index 312c593..9a73b87 100644 --- a/src/infrastructure/monitoring/logging/mod.rs +++ b/src/infrastructure/monitoring/logging/mod.rs @@ -6,7 +6,7 @@ use std::{ use chrono::Local; use multi_log_file_writer::{create_non_blocking_writer, get_fmt_layer, MultiLogFileWriter}; use tracing_appender::non_blocking::WorkerGuard; -use tracing_subscriber::{reload::Handle, Layer, Registry}; +use tracing_subscriber::{reload, reload::Handle, Layer, Registry}; pub mod garbage_collector; pub mod multi_log_file_writer; @@ -33,7 +33,7 @@ pub fn init_logging_layer() -> InitLoggingLayerProduct { let fmt_layer = get_fmt_layer(multi_log_file_writer.clone()); - let (reload_layer, reload_handle) = tracing_subscriber::reload::Layer::new(fmt_layer); + let (reload_layer, reload_handle) = reload::Layer::new(fmt_layer); InitLoggingLayerProduct { logging_layer: Box::new(reload_layer), diff --git a/src/infrastructure/monitoring/logging/multi_log_file_writer.rs b/src/infrastructure/monitoring/logging/multi_log_file_writer.rs index cebbb26..df90b2a 100644 --- a/src/infrastructure/monitoring/logging/multi_log_file_writer.rs +++ b/src/infrastructure/monitoring/logging/multi_log_file_writer.rs @@ -9,8 +9,11 @@ use std::{ }; use tracing::{event, Level}; -use tracing_appender::non_blocking::{NonBlocking, WorkerGuard}; -use tracing_subscriber::{reload::Handle, Layer, Registry}; +use tracing_appender::{ + non_blocking::{NonBlocking, WorkerGuard}, + rolling, +}; +use tracing_subscriber::{fmt, reload::Handle, Layer, Registry}; use crate::config::ConfigSnapshot; @@ -155,11 +158,11 @@ impl Write for MultiLogFileWriter { } pub fn get_fmt_layer(writer: MultiLogFileWriter) -> Box + Send + Sync> { - tracing_subscriber::fmt::layer() + fmt::layer() .with_writer(move || writer.clone()) .with_file(true) .with_line_number(true) - .with_timer(tracing_subscriber::fmt::time::SystemTime) + .with_timer(fmt::time::SystemTime) .json() .boxed() } @@ -170,6 +173,6 @@ pub fn create_non_blocking_writer(directory: &str, file_name: &str) -> (NonBlock let log_path = Path::new(directory).join(file_name); let _ = File::create(&log_path); - let file_appender = tracing_appender::rolling::never(directory, file_name); + let file_appender = rolling::never(directory, file_name); tracing_appender::non_blocking(file_appender) } diff --git a/src/infrastructure/net/mod.rs b/src/infrastructure/net/mod.rs index dbf8417..cef88f0 100644 --- a/src/infrastructure/net/mod.rs +++ b/src/infrastructure/net/mod.rs @@ -10,8 +10,7 @@ mod tests; use std::time::Duration; -use ureq::tls::TlsConfig; -use ureq::Agent; +use ureq::{config::AutoHeaderValue, tls::TlsConfig, Agent}; pub struct UreqNetClient { agent: Agent, @@ -21,7 +20,7 @@ impl UreqNetClient { pub fn new() -> Self { let kw_agent = format!("kworkflow/patch-hub/{}", env!("CARGO_PKG_VERSION")); let agent: Agent = Agent::config_builder() - .user_agent(ureq::config::AutoHeaderValue::from(kw_agent)) + .user_agent(AutoHeaderValue::from(kw_agent)) .timeout_per_call(Some(Duration::from_secs(120))) .tls_config(TlsConfig::builder().build()) .build() diff --git a/src/infrastructure/net/trait.rs b/src/infrastructure/net/trait.rs index 02df382..13ed14d 100644 --- a/src/infrastructure/net/trait.rs +++ b/src/infrastructure/net/trait.rs @@ -1,5 +1,6 @@ use mockall::automock; use thiserror::Error; +use ureq::Error as UreqError; pub enum HttpMethod { Get, @@ -20,11 +21,11 @@ pub enum NetError { impl From for NetError { fn from(e: ureq::Error) -> Self { match e { - ureq::Error::StatusCode(code) => NetError::HttpStatus { + UreqError::StatusCode(code) => NetError::HttpStatus { code, message: format!("HTTP {code}"), }, - ureq::Error::Timeout(_) => NetError::Timeout(e.to_string()), + UreqError::Timeout(_) => NetError::Timeout(e.to_string()), _ => NetError::ConnectionError(e.to_string()), } } diff --git a/src/infrastructure/process/mod.rs b/src/infrastructure/process/mod.rs index f1e6d3a..f5ee98b 100644 --- a/src/infrastructure/process/mod.rs +++ b/src/infrastructure/process/mod.rs @@ -17,7 +17,7 @@ pub use fake::FakeProcess; mod tests; use std::{ - fs::File, + fs::{self, File}, io, path::Path, process::{ExitStatus, Stdio}, @@ -57,7 +57,7 @@ impl ProcessTrait for OsProcess { .spawn() .inspect_err(|_| { // a job that never started must not leave a truncated log - let _ = std::fs::remove_file(log_path); + let _ = fs::remove_file(log_path); })?; let pid = child diff --git a/src/infrastructure/process/tests.rs b/src/infrastructure/process/tests.rs index 76df501..93b122d 100644 --- a/src/infrastructure/process/tests.rs +++ b/src/infrastructure/process/tests.rs @@ -13,17 +13,22 @@ use super::{ RunningProcess, }; use crate::infrastructure::shell::ShellCommand; +use nix::sys::signal::Signal; +use std::env; +use std::fs; +use std::process; +use tokio::time; struct TempDir(PathBuf); impl TempDir { fn new(test_name: &str) -> Self { - let dir = std::env::temp_dir().join(format!( + let dir = env::temp_dir().join(format!( "patch_hub_process_test_{}_{test_name}", - std::process::id() + process::id() )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); Self(dir) } @@ -34,7 +39,7 @@ impl TempDir { impl Drop for TempDir { fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); + let _ = fs::remove_dir_all(&self.0); } } @@ -44,16 +49,13 @@ async fn wait_for(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool { if start.elapsed() > timeout { return false; } - tokio::time::sleep(Duration::from_millis(25)).await; + time::sleep(Duration::from_millis(25)).await; } true } fn pid_is_gone(pid: i32) -> bool { - matches!( - kill(Pid::from_raw(pid), None::), - Err(Errno::ESRCH) - ) + matches!(kill(Pid::from_raw(pid), None::), Err(Errno::ESRCH)) } // The shell redirection creates the sidecar file before `echo $!` writes into @@ -63,7 +65,7 @@ async fn await_sidecar_pid(path: &Path) -> i32 { let mut pid = None; wait_for( || { - pid = std::fs::read_to_string(path) + pid = fs::read_to_string(path) .ok() .and_then(|contents| contents.trim().parse::().ok()); pid.is_some() @@ -83,12 +85,12 @@ async fn spawn_returns_while_process_still_running() { let mut process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); // spawn returned while the child was still inside its sleep - let early_log = std::fs::read_to_string(&log).unwrap(); + let early_log = fs::read_to_string(&log).unwrap(); assert!(!early_log.contains("done")); let status = process.wait().await.unwrap(); assert!(status.success()); - let final_log = std::fs::read_to_string(&log).unwrap(); + let final_log = fs::read_to_string(&log).unwrap(); assert!(final_log.contains("done")); } @@ -117,7 +119,7 @@ async fn log_file_grows_incrementally_with_stdout_and_stderr() { // output reaches the file while the process is still running, not at exit let saw_partial_log = wait_for( || { - let contents = std::fs::read_to_string(&log).unwrap(); + let contents = fs::read_to_string(&log).unwrap(); contents.contains("first") && contents.contains("errline") && !contents.contains("second") @@ -129,7 +131,7 @@ async fn log_file_grows_incrementally_with_stdout_and_stderr() { let status = process.wait().await.unwrap(); assert!(status.success()); - let contents = std::fs::read_to_string(&log).unwrap(); + let contents = fs::read_to_string(&log).unwrap(); assert!(contents.contains("first")); assert!(contents.contains("errline")); assert!(contents.contains("second")); @@ -145,7 +147,7 @@ async fn spawn_runs_in_given_cwd() { let status = process.wait().await.unwrap(); assert!(status.success()); - let out = std::fs::read_to_string(&log).unwrap(); + let out = fs::read_to_string(&log).unwrap(); let expected = dir.path().canonicalize().unwrap(); assert_eq!(out.trim(), expected.to_string_lossy()); } @@ -163,7 +165,7 @@ async fn kill_terminates_process_group() { let grandchild_pid = await_sidecar_pid(&sidecar).await; process.kill().unwrap(); - let status = tokio::time::timeout(Duration::from_secs(2), process.wait()) + let status = time::timeout(Duration::from_secs(2), process.wait()) .await .expect("wait must complete shortly after kill") .unwrap(); @@ -251,10 +253,10 @@ async fn fake_process_records_spawn_and_simulates_run() { control.write_log(b"partial output\n"); // still running: wait must not resolve before finish() is called - let early_wait = tokio::time::timeout(Duration::from_millis(50), process.wait()).await; + let early_wait = time::timeout(Duration::from_millis(50), process.wait()).await; assert!(early_wait.is_err()); - let log_so_far = std::fs::read_to_string(&log).unwrap(); + let log_so_far = fs::read_to_string(&log).unwrap(); assert_eq!(log_so_far, "partial output\n"); control.finish(0); diff --git a/src/infrastructure/terminal.rs b/src/infrastructure/terminal.rs index 852728f..7e0f311 100644 --- a/src/infrastructure/terminal.rs +++ b/src/infrastructure/terminal.rs @@ -2,7 +2,7 @@ //! emergency restore hooks. //! //! Normal runtime startup uses [`init`] from `main`, session operations go -//! through [`crate::terminal::session::CrosstermTerminalSession`], and the +//! through [`session::CrosstermTerminalSession`], and the //! panic hook plus `main`'s fatal-error path call [`restore`] directly. use ratatui::{ diff --git a/src/input/actor.rs b/src/input/actor.rs index e9ca846..2b59061 100644 --- a/src/input/actor.rs +++ b/src/input/actor.rs @@ -1,14 +1,14 @@ //! Input mediation actor: polls the terminal and maps raw events to semantic -//! [`InputEvent`](crate::input::event::InputEvent) values for the application. +//! `InputEvent` values for the application. //! //! A dedicated pump subtask calls -//! [`TerminalHandle::poll_event`](crate::terminal::handle::TerminalHandle::poll_event) +//! `TerminalHandle::poll_event` //! so an in-flight poll is never abandoned when a control message wins the //! select race. Mapped events are delivered to the subscriber channel //! registered via -//! [`InputHandle::subscribe_app`](crate::input::handle::InputHandle::subscribe_app); +//! `InputHandle::subscribe_app`; //! context updates from -//! [`InputHandle::update_context`](crate::input::handle::InputHandle::update_context) +//! `InputHandle::update_context` //! change key bindings without restarting the pump. use std::time::Duration; diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index 84ace13..b8a16f1 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -29,6 +29,11 @@ use crate::{ }; use super::*; +use chrono::DateTime; +use std::env; +use std::fs; +use std::process; +use tokio::time; static TEST_SEQ: AtomicU64 = AtomicU64::new(0); @@ -36,12 +41,12 @@ static TEST_SEQ: AtomicU64 = AtomicU64::new(0); /// parent must exist even though the fs trait is mocked. fn tmp_log_dir(test_name: &str) -> PathBuf { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!( + let dir = env::temp_dir().join(format!( "patch-hub-kw-actor-{}-{test_name}-{n}", - std::process::id() + process::id() )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); dir } @@ -242,7 +247,7 @@ fn expect_ready_tree(fs: &mut MockFileSystemTrait) { /// disk. fn read_real_job_log(path: &Path) -> Option> { (path.extension() == Some("log".as_ref())) - .then(|| std::fs::read_to_string(path).map_err(FileSystemError::from)) + .then(|| fs::read_to_string(path).map_err(FileSystemError::from)) } /// A ready kernel tree whose log dir can be created. @@ -573,7 +578,7 @@ async fn get_readiness_composes_probes_and_head_branch() { .withf(|name| name == "kw") .returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); let mut fs = MockFileSystemTrait::new(); fs.expect_is_file().returning(|_| false); fs.expect_is_dir().returning(|_| false); @@ -632,7 +637,7 @@ async fn get_readiness_for_branch_looks_up_that_branch_not_head() { .withf(|name| name == "kw") .returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); let mut fs = MockFileSystemTrait::new(); fs.expect_is_file().returning(|_| false); fs.expect_is_dir().returning(|_| false); @@ -682,7 +687,7 @@ async fn get_readiness_for_branch_looks_up_that_branch_not_head() { pub(super) async fn wait_for_terminal_status( watch: &mut watch::Receiver, ) -> KwJobStatus { - tokio::time::timeout(Duration::from_secs(10), async { + time::timeout(Duration::from_secs(10), async { loop { let status = watch.borrow().job.clone(); if !matches!(status, KwJobStatus::Idle | KwJobStatus::Running { .. }) { @@ -699,7 +704,7 @@ pub(super) async fn wait_for_running_phase( watch: &mut watch::Receiver, phase: KwPhase, ) -> KwJobStatus { - tokio::time::timeout(Duration::from_secs(10), async { + time::timeout(Duration::from_secs(10), async { loop { let status = watch.borrow().job.clone(); if matches!( @@ -721,7 +726,7 @@ async fn start_build_replies_immediately_and_runs_in_background() { // start_build resolves while the spawned process is still running // (no finish() was ever signaled). - let result = tokio::time::timeout(Duration::from_secs(1), handle.start_build(start_request())) + let result = time::timeout(Duration::from_secs(1), handle.start_build(start_request())) .await .expect("start_build must reply immediately"); result.unwrap(); @@ -762,7 +767,7 @@ async fn start_build_replies_immediately_and_runs_in_background() { ); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -792,7 +797,7 @@ async fn start_build_merges_extra_args_into_the_spawned_argv() { process.last_child().finish(0); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -806,7 +811,7 @@ async fn second_start_while_running_is_refused() { process.last_child().finish(0); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -845,7 +850,7 @@ async fn failed_build_reports_exit_code_and_log_path() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -856,7 +861,7 @@ async fn cancel_kills_process_group_and_reports_cancelled() { handle.start_build(start_request()).await.unwrap(); // The ack is immediate: process death is observed via the status, // not the reply. - tokio::time::timeout(Duration::from_secs(1), handle.cancel()) + time::timeout(Duration::from_secs(1), handle.cancel()) .await .expect("cancel must ack immediately") .unwrap(); @@ -877,7 +882,7 @@ async fn cancel_kills_process_group_and_reports_cancelled() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -889,7 +894,7 @@ async fn shutdown_kills_running_job() { handle.shutdown().await; assert!(process.last_child().was_killed()); - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -907,7 +912,7 @@ async fn spawn_failure_refuses_start_and_stays_idle() { process.last_child().finish(0); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -934,7 +939,7 @@ async fn start_build_refused_when_kw_binary_missing() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -978,7 +983,7 @@ async fn start_build_refused_when_tree_not_ready() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -993,7 +998,7 @@ async fn start_build_allowed_when_kw_version_below_floor() { process.last_child().finish(0); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1053,7 +1058,7 @@ async fn start_build_switches_to_requested_branch_before_spawning() { process.last_child().finish(0); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1076,7 +1081,7 @@ async fn start_build_refused_when_worktree_is_dirty() { .any(|call| call.iter().any(|part| part == "switch"))); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1098,7 +1103,7 @@ async fn start_build_refused_when_git_state_is_unverifiable() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1121,7 +1126,7 @@ async fn start_build_refused_when_branch_switch_fails() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1134,10 +1139,10 @@ async fn cancel_and_restore_without_job_are_immediate_errors() { MockEnvTrait::new(), ); - let cancel = tokio::time::timeout(Duration::from_secs(1), handle.cancel()) + let cancel = time::timeout(Duration::from_secs(1), handle.cancel()) .await .expect("cancel must reply immediately"); - let restore = tokio::time::timeout(Duration::from_secs(1), handle.restore_previous_branch()) + let restore = time::timeout(Duration::from_secs(1), handle.restore_previous_branch()) .await .expect("restore must reply immediately"); @@ -1177,7 +1182,7 @@ async fn restore_switches_back_to_pre_job_branch_and_is_consumed() { assert!(matches!(err, KwError::NoRecordedBranch)); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1199,7 +1204,7 @@ async fn restore_refused_while_job_is_running() { assert_eq!(git.head(), "master"); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1229,7 +1234,7 @@ async fn restore_refused_when_worktree_is_dirty() { assert_eq!(git.head(), "master"); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1253,7 +1258,7 @@ async fn restore_failure_keeps_the_context_for_a_retry() { assert_eq!(git.head(), "master"); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1282,7 +1287,7 @@ async fn refused_start_does_not_clobber_the_restore_context() { assert_eq!(git.head(), "master"); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1327,7 +1332,7 @@ async fn log_dir_creation_failure_refuses_start_and_stays_idle() { )); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1352,7 +1357,7 @@ async fn rollback_failure_keeps_the_spawn_refusal() { )); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1376,7 +1381,7 @@ async fn cancel_racing_a_successful_exit_reports_success() { assert!(!process.last_child().was_killed()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1406,7 +1411,7 @@ async fn cancel_racing_a_failed_exit_reports_failure() { ); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } // Paused time: the runtime auto-advances through the grace-period @@ -1430,7 +1435,7 @@ async fn cancel_escalates_to_sigkill_when_sigterm_is_ignored() { assert!(child.was_force_killed()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1468,11 +1473,11 @@ async fn successful_build_writes_a_full_build_record() { assert!(record.log_path.starts_with(log_dir.to_str().unwrap())); assert!(record.success); // The readiness latest-lookup parses built_at as RFC3339. - assert!(chrono::DateTime::parse_from_rfc3339(&record.built_at).is_ok()); + assert!(DateTime::parse_from_rfc3339(&record.built_at).is_ok()); } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1513,7 +1518,7 @@ async fn failed_build_writes_a_failure_record() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1536,7 +1541,7 @@ async fn cancelled_build_writes_no_record() { assert!(builds.lock().unwrap().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1572,7 +1577,7 @@ async fn lost_exit_status_records_a_failed_build() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1602,7 +1607,7 @@ async fn build_record_write_failure_keeps_the_terminal_status() { assert!(matches!(status, KwJobStatus::Succeeded { .. })); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1641,7 +1646,7 @@ async fn build_record_keeps_no_patchset_link_when_apply_lookup_fails() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -1729,5 +1734,5 @@ async fn successful_build_with_active_env_records_the_output_dir() { assert_eq!(1, env_current_reads.load(Ordering::SeqCst)); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } diff --git a/src/kw/actor/deploy_test.rs b/src/kw/actor/deploy_test.rs index 98fe8e3..4fcf24a 100644 --- a/src/kw/actor/deploy_test.rs +++ b/src/kw/actor/deploy_test.rs @@ -1,10 +1,13 @@ +use std::fs; + +use tokio::time; + +use super::{actor_test::*, *}; use crate::kw::{ history::MockKwHistoryStore, models::{readiness::DeployAloneRefusal, remote::RemoteRefusal}, }; -use super::{actor_test::*, *}; - #[tokio::test] async fn start_deploy_replies_immediately_and_runs_in_background() { let (handle, process, log_dir) = spawn_deploy_actor( @@ -13,7 +16,7 @@ async fn start_deploy_replies_immediately_and_runs_in_background() { deploy_ready_fs(), ); - let result = tokio::time::timeout( + let result = time::timeout( Duration::from_secs(1), handle.start_deploy(deploy_request()), ) @@ -73,7 +76,7 @@ async fn start_deploy_replies_immediately_and_runs_in_background() { ); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -111,7 +114,7 @@ async fn deploy_exit_zero_with_initramfs_failure_succeeds_with_warnings() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -145,7 +148,7 @@ async fn build_then_deploy_checks_grub_for_the_release_it_just_built() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -183,7 +186,7 @@ async fn start_deploy_merges_extras_and_follows_reboot_force_options() { process.last_child().finish(0); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -201,7 +204,7 @@ async fn start_deploy_refused_without_a_build_record() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -223,7 +226,7 @@ async fn start_deploy_refused_when_last_build_failed() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -245,7 +248,7 @@ async fn start_deploy_refused_on_tree_path_drift() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -267,7 +270,7 @@ async fn start_deploy_refused_on_output_dir_mismatch() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -287,7 +290,7 @@ async fn start_deploy_refused_when_image_is_missing() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -307,7 +310,7 @@ async fn start_deploy_refused_when_remote_is_unresolved() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -324,7 +327,7 @@ async fn start_deploy_refused_when_boot_once_is_on_and_unacked() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -348,7 +351,7 @@ async fn start_deploy_refused_when_latest_build_is_on_another_branch() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -366,7 +369,7 @@ async fn start_deploy_proceeds_when_boot_once_is_on_and_acked() { process.last_child().finish(0); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -394,7 +397,7 @@ async fn start_deploy_post_switch_refusal_rolls_the_switch_back() { )); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -409,7 +412,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { .into_iter() .map(String::from) .collect(); - tokio::time::timeout( + time::timeout( Duration::from_secs(1), handle.start_build_then_deploy(request), ) @@ -492,7 +495,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { assert_eq!(1, builds.lock().unwrap().len()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -530,7 +533,7 @@ async fn start_build_then_deploy_skips_deploy_when_the_build_fails() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -559,7 +562,7 @@ async fn start_build_then_deploy_cancel_in_building_skips_deploy_and_record() { assert!(builds.lock().unwrap().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -589,7 +592,7 @@ async fn start_build_then_deploy_cancel_then_exit_zero_skips_deploy() { assert!(builds.lock().unwrap().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -625,7 +628,7 @@ async fn start_build_then_deploy_cancel_in_deploying_keeps_the_build_record() { } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -665,7 +668,7 @@ async fn start_build_then_deploy_spawn_failure_at_boundary_keeps_the_build_recor } handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } #[tokio::test] @@ -688,5 +691,5 @@ async fn start_build_then_deploy_refused_when_remote_is_unresolved() { assert!(process.spawned().is_empty()); handle.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).unwrap(); } diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 46652ac..a7beab4 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -1,12 +1,12 @@ //! kw actor: owns kw build/deploy job state and the kw history store. //! -//! All kw operations go through [`KwHandle`](crate::kw::handle::KwHandle) as +//! All kw operations go through `KwHandle` as //! typed request/reply messages. `Start*` messages reply immediately with an //! accept/refuse verdict — a job accepted by the actor keeps running after //! the caller has been answered, so the AppActor loop never blocks on a //! kernel build. The actor composes the readiness probes from -//! [`crate::kw::readiness`] and records applies through the shared -//! [`KwHistoryStore`](crate::kw::history::KwHistoryStore). +//! [`kw::readiness`] and records applies through the shared +//! `KwHistoryStore`. //! //! Git checkout and readiness probes run on the blocking pool. //! `create_dir_all`, history writes, and completion probes run inline @@ -20,9 +20,11 @@ use std::{ time::Duration, }; +use chrono::Utc; use tokio::{ spawn, sync::{mpsc, oneshot, watch}, + task, time, }; use crate::infrastructure::actor_reply::ActorReplyService; @@ -301,7 +303,7 @@ impl KwActor { let env = Arc::clone(&self.env); let shell = Arc::clone(&self.shell); let tree_path_for_probe = tree_path.clone(); - let (kw_binary, output_dir, tree_readiness) = tokio::task::spawn_blocking(move || { + let (kw_binary, output_dir, tree_readiness) = task::spawn_blocking(move || { let kw_binary = readiness::ReadinessService::probe_kw_binary(&*env, &*shell); if !kw_binary.available { return Err(KwStartError::KwBinaryMissing); @@ -450,7 +452,7 @@ impl KwActor { let tree_path = tree_path.to_path_buf(); let output_dir = output_dir.map(Path::to_path_buf); let arch = arch.map(str::to_string); - tokio::task::spawn_blocking(move || { + task::spawn_blocking(move || { Self::prepare_deploy_blocking( kind, &request, @@ -482,17 +484,16 @@ impl KwActor { let shell = Arc::clone(&self.shell); let tree_path = request.tree.path().to_string(); let branch = request.branch.clone(); - let pre_job_branch = - tokio::task::spawn_blocking(move || -> Result { - KwGitService::check_worktree_clean(&*shell, &tree_path)?; - let pre_job_branch = KwGitService::probe_head_branch(&*shell, &tree_path); - KwGitService::switch_to_branch(&*shell, &tree_path, &branch)?; - Ok(pre_job_branch) - }) - .await - // A join error means the probe task panicked — a bug, surfaced as - // an unverifiable git state rather than wedging the actor. - .map_err(|error| KwStartError::GitStateProbe(error.to_string()))??; + let pre_job_branch = task::spawn_blocking(move || -> Result { + KwGitService::check_worktree_clean(&*shell, &tree_path)?; + let pre_job_branch = KwGitService::probe_head_branch(&*shell, &tree_path); + KwGitService::switch_to_branch(&*shell, &tree_path, &branch)?; + Ok(pre_job_branch) + }) + .await + // A join error means the probe task panicked — a bug, surfaced as + // an unverifiable git state rather than wedging the actor. + .map_err(|error| KwStartError::GitStateProbe(error.to_string()))??; Ok(if pre_job_branch.is_empty() { None } else { @@ -516,7 +517,7 @@ impl KwActor { let tree_path = tree_path.to_string(); let branch = branch.to_string(); let branch_for_task = branch.clone(); - match tokio::task::spawn_blocking(move || { + match task::spawn_blocking(move || { KwGitService::switch_to_branch(&*shell, &tree_path, &branch_for_task) }) .await @@ -546,7 +547,7 @@ impl KwActor { // not share a log file — spawn truncates it. let log_path = self.kw_log_dir.join(format!( "build-{}.log", - chrono::Utc::now().format("%Y%m%d-%H%M%S-%3f") + Utc::now().format("%Y%m%d-%H%M%S-%3f") )); let cmd = ShellCommand::new("kw").args(argv::KwArgvService::build_argv(&request.extra_args)); @@ -565,7 +566,7 @@ impl KwActor { self.fs.create_dir_all(&self.kw_log_dir)?; let log_path = self.kw_log_dir.join(format!( "deploy-{}.log", - chrono::Utc::now().format("%Y%m%d-%H%M%S-%3f") + Utc::now().format("%Y%m%d-%H%M%S-%3f") )); let cmd = ShellCommand::new("kw").args(argv::KwArgvService::build_deploy_argv( &deploy.remote.endpoint(), @@ -833,7 +834,7 @@ impl KwActor { .map(|path| path.to_string_lossy().into_owned()), kernelrelease: kernelrelease.clone(), log_path: job.log_path.to_string_lossy().into_owned(), - built_at: chrono::Utc::now().to_rfc3339(), + built_at: Utc::now().to_rfc3339(), success, }; if let Err(error) = self.history.record_build(record) { @@ -891,7 +892,7 @@ impl KwActor { let history = Arc::clone(&self.history); let kernel_tree_id = kernel_tree_id.to_string(); let tree = tree.clone(); - tokio::task::spawn_blocking(move || { + task::spawn_blocking(move || { let head = KwGitService::probe_head_branch(&*shell, tree.path()); readiness::ReadinessService::evaluate_readiness( &*fs, @@ -926,7 +927,7 @@ impl KwActor { let branch = restore.branch.clone(); // Same spawn_blocking rationale as the start path's checkout: the // switch rewrites the worktree. - let result = tokio::task::spawn_blocking(move || -> Result<(), TreeGitError> { + let result = task::spawn_blocking(move || -> Result<(), TreeGitError> { KwGitService::check_worktree_clean(&*shell, &tree_path)?; KwGitService::switch_to_branch(&*shell, &tree_path, &branch) }) @@ -1039,14 +1040,14 @@ impl KwActor { if let Err(error) = process.kill() { tracing::warn!(%error, "failed to SIGTERM kw job process group"); } - match tokio::time::timeout(TERM_GRACE, process.wait()).await { + match time::timeout(TERM_GRACE, process.wait()).await { Ok(outcome) => Self::map_outcome_after_cancel(outcome), Err(_) => { tracing::warn!("kw job ignored SIGTERM; escalating to SIGKILL"); if let Err(error) = process.force_kill() { tracing::warn!(%error, "failed to SIGKILL kw job process group"); } - match tokio::time::timeout(KILL_GRACE, process.wait()).await { + match time::timeout(KILL_GRACE, process.wait()).await { Ok(outcome) => Self::map_outcome_after_cancel(outcome), Err(_) => { tracing::warn!( diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 335e981..8faf016 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -1,6 +1,8 @@ //! kw argv construction: patch-hub's base command lines plus the user //! extra-args merge in which reserved options always win. +use std::ptr; + /// A CLI option patch-hub controls: user-supplied extra args that set it /// are stripped, so the occurrence on patch-hub's own base argv wins. pub struct ReservedOption { @@ -229,7 +231,7 @@ impl KwArgvService { } match found { None => found = Some(option), - Some(previous) if std::ptr::eq(previous, option) => {} + Some(previous) if ptr::eq(previous, option) => {} Some(_) => return None, } } diff --git a/src/kw/history.rs b/src/kw/history.rs index 4ef58a8..26ca978 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -13,6 +13,8 @@ use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait, JsonU use crate::kw::models::history::KwApplyRecord; use crate::kw::models::history::KwBuildRecord; +use chrono::DateTime; +use serde::de::DeserializeOwned; pub const APPLY_HISTORY_FILENAME: &str = "kw_apply_history.json"; pub const BUILD_HISTORY_FILENAME: &str = "kw_build_history.json"; @@ -81,7 +83,7 @@ impl FileKwHistoryStore { /// must never be silently clobbered by the next write. fn load_records(&self, path: &str) -> Result where - T: serde::de::DeserializeOwned + Default, + T: DeserializeOwned + Default, { let path_ref = Path::new(path); if !self.fs.is_file(path_ref) { @@ -135,9 +137,7 @@ impl KwHistoryStore for FileKwHistoryStore { .values() .filter_map(|by_tree| by_tree.get(kernel_tree_id)) .filter(|record| record.applied_branch == branch) - .max_by_key(|record| { - chrono::DateTime::parse_from_rfc3339(&record.applied_at).ok() - }) + .max_by_key(|record| DateTime::parse_from_rfc3339(&record.applied_at).ok()) .cloned() }) .map_err(|e| self.error_with_path(&self.apply_history_path, e)) @@ -162,9 +162,7 @@ impl KwHistoryStore for FileKwHistoryStore { let latest = by_branch.and_then(|by_branch| { by_branch .values() - .max_by_key(|record| { - chrono::DateTime::parse_from_rfc3339(&record.built_at).ok() - }) + .max_by_key(|record| DateTime::parse_from_rfc3339(&record.built_at).ok()) .cloned() }); (record, latest) @@ -182,15 +180,17 @@ mod tests { use crate::infrastructure::file_system::OsFileSystem; use super::*; + use std::env; + use std::process; static TEST_SEQ: AtomicU64 = AtomicU64::new(0); fn tmp_dir(test_name: &str) -> PathBuf { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!( + let dir = env::temp_dir().join(format!( "patch-hub-kw-history-{}-{}-{}", test_name, - std::process::id(), + process::id(), n )); // A leftover from a failed previous run (pid reuse + counter reset) diff --git a/src/kw/models/readiness.rs b/src/kw/models/readiness.rs index 0172858..94e59e5 100644 --- a/src/kw/models/readiness.rs +++ b/src/kw/models/readiness.rs @@ -1,6 +1,6 @@ use thiserror::Error; -use std::path::PathBuf; +use std::{fmt, path::PathBuf}; use crate::{ infrastructure::{env::EnvError, file_system::FileSystemError}, @@ -24,7 +24,7 @@ pub enum TreeReadiness { /// `arch=` value from `.kw/build.config`; `None` means the key is unset /// and image discovery will glob `arch/*/boot/` instead — a deliberate /// divergence from kw, whose own fallback is the merged kw-config - /// `arch` (see [`crate::kw::readiness::ReadinessService::find_newest_kernel_image`]). + /// `arch` (see [`ReadinessService::find_newest_kernel_image`]). Ready { arch: Option }, /// The configured path is not a directory. Missing, @@ -65,8 +65,8 @@ pub struct KwBinaryProbe { pub check: KwVersionCheck, } -impl std::fmt::Display for TreeReadiness { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for TreeReadiness { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { TreeReadiness::Ready { .. } => write!(f, "ready"), TreeReadiness::Missing => write!(f, "the configured path is not a directory"), diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 54ee19b..d65adf2 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -579,6 +579,9 @@ mod tests { use super::*; + use std::env; + use std::io; + use std::process; use std::sync::Arc; static TEST_SEQ: AtomicU64 = AtomicU64::new(0); @@ -588,10 +591,10 @@ mod tests { impl TempDir { fn new(test_name: &str) -> Self { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = std::env::temp_dir().join(format!( + let dir = env::temp_dir().join(format!( "patch-hub-kw-readiness-{}-{}-{}", test_name, - std::process::id(), + process::id(), n )); // A leftover from a failed previous run must not poison this one. @@ -931,7 +934,7 @@ last_line_without_newline=yes"; let mut env = MockEnvTrait::new(); env.expect_var() .withf(|key| key == "XDG_CACHE_HOME") - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); env.expect_var() .withf(|key| key == "HOME") .returning(|_| Ok("/home/user".to_string())); @@ -1019,9 +1022,8 @@ last_line_without_newline=yes"; fn resolve_output_dir_unreadable_env_file_errors() { let mut fs = MockFileSystemTrait::new(); fs.expect_is_file().returning(|_| true); - fs.expect_read_to_string().returning(|_| { - Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied").into()) - }); + fs.expect_read_to_string() + .returning(|_| Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied").into())); let env = MockEnvTrait::new(); assert!(ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); @@ -1035,7 +1037,7 @@ last_line_without_newline=yes"; .returning(|_| Ok("minix\n".to_string())); let mut env = MockEnvTrait::new(); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); assert!(ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); } @@ -1251,7 +1253,7 @@ last_line_without_newline=yes"; let mut spawn_fails = MockShellTrait::new(); spawn_fails .expect_execute() - .returning(|_| Err(std::io::Error::other("spawn failed").into())); + .returning(|_| Err(io::Error::other("spawn failed").into())); let mut empty_stdout = MockShellTrait::new(); empty_stdout @@ -1510,7 +1512,7 @@ last_line_without_newline=yes"; let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); @@ -1553,7 +1555,7 @@ last_line_without_newline=yes"; let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); @@ -1592,7 +1594,7 @@ last_line_without_newline=yes"; let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); let tree = kernel_tree(dir.path()); @@ -1629,7 +1631,7 @@ last_line_without_newline=yes"; let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); shell.expect_execute().times(0); let tree = kernel_tree(dir.path()); @@ -1701,7 +1703,7 @@ last_line_without_newline=yes"; let empty = make_ready_tree("boot-once-missing"); let mut env = MockEnvTrait::new(); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .returning(|_| Err(env::VarError::NotPresent.into())); assert_eq!( BootOnceState::Unknown, ReadinessService::probe_boot_once(&OsFileSystem, &env, empty.path()) @@ -1749,8 +1751,8 @@ last_line_without_newline=yes"; fs.expect_is_file() .returning(|path| path.ends_with(".kw/deploy.config")); fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::PermissionDenied, "denied", ))) }); diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 31f7500..a39b057 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -135,7 +135,7 @@ impl RemoteConfigService { } } - /// Resolves the remote kw deploy will be pointed at. See the [`remote`](crate::kw::remote) + /// Resolves the remote kw deploy will be pointed at. See the `remote` /// module docs for the file lookup order. pub fn resolve_deploy_remote( fs: &dyn FileSystemTrait, @@ -227,6 +227,8 @@ mod tests { use super::*; use crate::kw::models::remote::RemoteRefusal; + use std::env; + use std::io; /// Example remote.config: two hosts, default on the second, fields indented. const SAMPLE_REMOTE_CONFIG: &str = "\ @@ -490,10 +492,7 @@ Host dut extra .returning(move |path| is_file.contains_key(path)); fs.expect_read_to_string().returning(move |path| { read.get(path).cloned().ok_or_else(|| { - FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing", - )) + FileSystemError::IoError(io::Error::new(io::ErrorKind::NotFound, "missing")) }) }); fs @@ -512,7 +511,7 @@ Host dut extra } fn missing_var() -> EnvError { - std::env::VarError::NotPresent.into() + env::VarError::NotPresent.into() } #[test] @@ -589,8 +588,8 @@ Host dut extra fs.expect_is_file() .returning(|path| path == Path::new("/kernel/.kw/remote.config")); fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::PermissionDenied, "denied", ))) }); diff --git a/src/kw/status.rs b/src/kw/status.rs index fc46068..6747c01 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -3,7 +3,7 @@ //! `AppState` never owns job state; it polls (`GetStatus`) or watches //! (`WatchStatus`) these snapshots and projects them into the view model. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KwJobKind { @@ -35,7 +35,7 @@ pub enum KwJobStatus { branch: String, log_path: PathBuf, /// Known deploy failures kw exited 0 through (see - /// [`crate::kw::log_scan::LogScanService::collect_deploy_warnings`]). Always empty for a + /// [`LogScanService::collect_deploy_warnings`]). Always empty for a /// build-only job. warnings: Vec, }, @@ -92,7 +92,7 @@ impl KwStatusSnapshot { impl KwJobStatus { /// Log file for the current or last job, if the actor has opened one. - pub fn log_path(&self) -> Option<&std::path::Path> { + pub fn log_path(&self) -> Option<&Path> { match self { Self::Idle => None, Self::Running { log_path, .. } diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index 6e6962b..579ca7b 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -2,7 +2,7 @@ //! //! All lore I/O (mailing lists, feed pages, patchset details, bookmarks, //! reviewed state, git reply preparation) goes through -//! [`LoreApiHandle`](crate::lore::application::handle::LoreApiHandle) as typed +//! `LoreApiHandle` as typed //! request/reply messages. Heavy work runs on a blocking thread pool via //! [`LoreApiActor::with_core`]; callers never touch [`LoreService`] directly. use std::ops::ControlFlow; diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index dfed9c5..c0a23bf 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -467,6 +467,7 @@ mod tests { http_lore_client::{ LoreHttpError, MockFeedGateway, MockListsGateway, MockPatchHtmlGateway, }, + parsers::LoreParserService, patchset_fetcher::MockPatchsetFetcher, patchset_parser::MockPatchsetParser, persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, @@ -760,7 +761,6 @@ mod tests { ); let feed = { - use crate::lore::infrastructure::parsers::LoreParserService; let xml = fs::read_to_string(src).unwrap(); LoreParserService::parse_patch_feed(&xml).unwrap() }; @@ -801,7 +801,7 @@ mod tests { // Pre-populate the cache. let feed = { let xml = fs::read_to_string(src).unwrap(); - crate::lore::infrastructure::parsers::LoreParserService::parse_patch_feed(&xml).unwrap() + LoreParserService::parse_patch_feed(&xml).unwrap() }; let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); diff --git a/src/lore/infrastructure/patchset_fetcher.rs b/src/lore/infrastructure/patchset_fetcher.rs index 52173b8..ff78567 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -107,8 +107,11 @@ mod tests { use super::*; use crate::infrastructure::{ file_system::MockFileSystemTrait, - shell::{MockShellTrait, ShellOutput}, + shell::{MockShellTrait, ShellError, ShellOutput}, }; + use crate::lore::infrastructure::parsers::LoreParserService; + use std::io; + use std::sync::atomic; // message_id → "linux-kernel.1234.567-1-john@johnson.com.mbx" // (trailing slash in href becomes the last dot, then "mbx") @@ -129,10 +132,7 @@ mod tests { "#, ); - crate::lore::infrastructure::parsers::LoreParserService::parse_patch_feed(&xml) - .unwrap() - .patches()[0] - .clone() + LoreParserService::parse_patch_feed(&xml).unwrap().patches()[0].clone() } #[test] @@ -170,7 +170,7 @@ mod tests { // Track how many times exists(CACHED_FILE) was called so we can // return false the first time (file missing) and true the second // time (after b4 fetched it). - let call_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let call_count = Arc::new(atomic::AtomicUsize::new(0)); let call_count_clone = call_count.clone(); let mut mock_fs = MockFileSystemTrait::new(); @@ -182,7 +182,7 @@ mod tests { .expect_exists() .withf(|p| p == Path::new(CACHED_FILE)) .returning(move |_| { - let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let n = call_count_clone.fetch_add(1, atomic::Ordering::SeqCst); n > 0 // false on first call (before b4), true on second (after b4) }); @@ -224,11 +224,10 @@ mod tests { .returning(|_| false); let mut mock_shell = MockShellTrait::new(); - mock_shell.expect_execute().times(1).returning(|_| { - Err(crate::infrastructure::shell::ShellError::IoError( - std::io::Error::other("b4 not found"), - )) - }); + mock_shell + .expect_execute() + .times(1) + .returning(|_| Err(ShellError::IoError(io::Error::other("b4 not found")))); let fetcher = B4PatchsetFetcher::new( Arc::new(mock_shell), diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index fcb36be..2f87857 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -204,6 +204,8 @@ mod tests { use crate::infrastructure::file_system::OsFileSystem; use super::*; + use std::env; + use std::process; fn parser() -> MboxPatchsetParser { MboxPatchsetParser::new(Arc::new(OsFileSystem)) @@ -230,10 +232,8 @@ mod tests { #[test] fn split_patchset_returns_error_on_invalid_utf8() { - let path = std::env::temp_dir().join(format!( - "patch-hub-mbox-invalid-utf8-{}.mbx", - std::process::id() - )); + let path = + env::temp_dir().join(format!("patch-hub-mbox-invalid-utf8-{}.mbx", process::id())); fs::write(&path, b"Subject: not utf-8\n\xff\n").expect("write invalid mbox"); let path_str = path.to_str().expect("temp path is utf-8"); let err = parser() diff --git a/src/lore/infrastructure/persistence.rs b/src/lore/infrastructure/persistence.rs index 2600a84..7f752ef 100644 --- a/src/lore/infrastructure/persistence.rs +++ b/src/lore/infrastructure/persistence.rs @@ -112,18 +112,20 @@ mod tests { use crate::lore::domain::mailing_list::MailingList; use super::*; + use std::path::PathBuf; + use std::process; - fn tmp_dir(test_name: &str) -> std::path::PathBuf { + fn tmp_dir(test_name: &str) -> PathBuf { let dir = env::temp_dir().join(format!( "patch-hub-persistence-{}-{}", test_name, - std::process::id() + process::id() )); fs::create_dir_all(&dir).unwrap(); dir } - fn make_persistence(dir: &std::path::Path) -> FileLorePersistence { + fn make_persistence(dir: &Path) -> FileLorePersistence { FileLorePersistence::new( Arc::new(OsFileSystem), dir.join("lists.json").to_str().unwrap().to_string(), diff --git a/src/main.rs b/src/main.rs index d7321aa..bfaf045 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ use color_eyre::{eyre::eyre, Result}; use config::{ConfigActor, ConfigService, ConfigSnapshot}; use infrastructure::{ env::OsEnv, + errors::{install_hooks, TerminalRestoreGuard}, file_system::{FileSystemTrait, OsFileSystem}, monitoring::{init_monitoring, InitMonitoringProduct}, net::UreqNetClient, @@ -55,7 +56,7 @@ async fn main() -> Result<()> { let args = Cli::parse(); - infrastructure::errors::install_hooks()?; + install_hooks()?; let env = OsEnv; let (config_state, config_repo) = @@ -198,7 +199,7 @@ async fn main() -> Result<()> { } .await; if let Err(error) = result { - infrastructure::errors::TerminalRestoreGuard::restore_once(); + TerminalRestoreGuard::restore_once(); return Err(error); } diff --git a/src/render/actor.rs b/src/render/actor.rs index 0405003..6c6b04b 100644 --- a/src/render/actor.rs +++ b/src/render/actor.rs @@ -1,9 +1,9 @@ //! Render actor: serializes patch/cover preview rendering on a dedicated task. //! -//! [`RenderHandle::render_patchset_preview`](crate::render::handle::RenderHandle::render_patchset_preview) -//! sends a [`RenderMessage`](crate::render::messages::RenderMessage) to this -//! actor, which delegates to a [`RenderServiceApi`](crate::render::RenderServiceApi) -//! implementation (typically [`ShellRenderService`](crate::render::ShellRenderService)) +//! `RenderHandle::render_patchset_preview` +//! sends a `RenderMessage` to this +//! actor, which delegates to a `RenderServiceApi` +//! implementation (typically `ShellRenderService`) //! on a blocking thread pool. Keeps shell subprocess work off the async runtime //! and the UI thread. use std::ops::ControlFlow; diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index e5587fe..abd27f1 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -1,8 +1,8 @@ //! Terminal session actor: owns raw TUI I/O on a dedicated task. //! -//! [`TerminalHandle`](crate::terminal::handle::TerminalHandle) exposes draw, +//! `TerminalHandle` exposes draw, //! poll event, size, and user-I/O setup as typed messages. The session -//! implementation ([`TerminalSessionApi`](crate::terminal::session::TerminalSessionApi), +//! implementation (`TerminalSessionApi`, //! e.g. crossterm) is moved into the actor at spawn time so no other component //! holds the terminal directly. use tokio::{spawn, sync::mpsc, task}; diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 48bb521..ba30ba6 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,10 +1,10 @@ //! Actor boundary for the Ratatui/Crossterm terminal session. //! //! This module is the single owner of terminal session operations. The runtime -//! draws frames through [`handle::TerminalHandle::draw`]. Raw terminal events +//! draws frames through [`TerminalHandle::draw`]. Raw terminal events //! are delivered to the [`crate::input`] actor via -//! [`handle::TerminalHandle::poll_event`]; the Input actor translates them into -//! semantic [`crate::input::event::InputEvent`] values before forwarding them +//! [`TerminalHandle::poll_event`]; the Input actor translates them into +//! semantic [`event::InputEvent`] values before forwarding them //! to the App. pub mod actor; diff --git a/src/ui/actor.rs b/src/ui/actor.rs index 6f075d1..69554f8 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -1,12 +1,12 @@ -//! UI presentation actor: builds [`UiScene`](crate::ui::scene::UiScene) values -//! from [`AppViewModel`](crate::app::models::view_model::AppViewModel) on a dedicated task. +//! UI presentation actor: builds `UiScene` values +//! from `AppViewModel` on a dedicated task. //! -//! [`AppActor`](crate::app::actor::AppActor) calls -//! [`UiHandle::build_scene`](crate::ui::handle::UiHandle::build_scene) each frame; -//! this actor runs [`UiCore::build_scene`](crate::ui::core::UiCore::build_scene) +//! `AppActor` calls +//! `UiHandle::build_scene` each frame; +//! this actor runs `UiCore::build_scene` //! and returns an owned scene for the terminal draw path. Presentation logic stays -//! out of [`AppState`](crate::app::state::AppState): the app layer projects domain -//! state into [`AppViewModel`](crate::app::models::view_model::AppViewModel) before +//! out of `AppState`: the app layer projects domain +//! state into `AppViewModel` before //! crossing this boundary. use std::{mem, ops::ControlFlow}; diff --git a/src/ui/scene.rs b/src/ui/scene.rs index aa53c8b..054c906 100644 --- a/src/ui/scene.rs +++ b/src/ui/scene.rs @@ -1,4 +1,4 @@ -//! Scene types produced by [`super::core::UiCore`] and consumed by +//! Scene types produced by [`core::UiCore`] and consumed by //! [`super::painter`]. //! //! A `UiScene` is a fully projected, paint-ready snapshot of application From 89693a7a038740a532070b66109d0d0cde744d24 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 13:55:02 -0300 Subject: [PATCH 49/61] docs: remove process and TODO comments Drop notes that record an old shape of the code, lab log paths, and a leftover TODO. Sentences that still state a real constraint stay, with that wording removed: PATCH_HUB_* overrides still apply onto config state, and the remote parser still matches Hostname, Port, and User by key rather than by position after Host. Signed-off-by: lorenzoberts --- src/config/env_overrides.rs | 2 +- src/kw/log_scan.rs | 12 +++++------- src/kw/models/readiness.rs | 2 +- src/kw/remote.rs | 4 ++-- src/lore/application/models/cache.rs | 2 +- src/lore/domain/patchset.rs | 2 -- src/main.rs | 2 +- src/ui/screens/details.rs | 1 - 8 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/config/env_overrides.rs b/src/config/env_overrides.rs index 1037b4a..9231aa0 100644 --- a/src/config/env_overrides.rs +++ b/src/config/env_overrides.rs @@ -6,7 +6,7 @@ use crate::infrastructure::env::EnvTrait; pub struct EnvOverrideService; impl EnvOverrideService { - /// Applies `PATCH_HUB_*` overrides (same semantics as legacy `Config::override_with_env_vars`). + /// Applies `PATCH_HUB_*` overrides onto `state`. pub fn apply_env_overrides( state: &mut ConfigState, env: &dyn EnvTrait, diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index 3ad3087..3c59454 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -116,7 +116,7 @@ mod tests { use super::*; /// `kw build` under an env `O=` with in-tree leftovers in the source - /// tree (lab/logs/20260920-122005). + /// tree. const UNCLEAN_TREE_BUILD_LOG: &str = "\ /boot/config-6.8.0-138-generic:883:warning: symbol value '0' invalid for BASE_SMALL SYNC include/config/auto.conf.cmd @@ -132,7 +132,7 @@ make[1]: *** [/opt/ph-lab/work/linux/Makefile:248: __sub-make] Error 2 make[1]: Leaving directory '/opt/ph-lab/work/out' "; - /// The M4 fixture's compile break (lab/logs/20260920-151635/M18). + /// A compile error that stops the build. const COMPILE_ERROR_BUILD_LOG: &str = "\ CC mm/filemap.o AS arch/arm64/kernel/entry.o @@ -146,9 +146,8 @@ make[3]: *** Waiting for unfinished jobs.... make: *** [Makefile:248: __sub-make] Error 2 "; - /// tinyconfig deploy to the Ubuntu DUT: kw exits 0 although - /// initramfs generation failed and GRUB never listed the kernel - /// (lab/logs/20261003-111949). + /// tinyconfig deploy: kw exits 0 although initramfs generation failed + /// and GRUB never listed the kernel. const TINYCONFIG_DEPLOY_LOG: &str = "\ cp /tmp/kw/kw_pkg/Image-7.2.0-rc6+ /boot/ generate_debian_temporary_root_file_system VERBOSE 7.2.0-rc6+ remote GRUB @@ -168,8 +167,7 @@ Adding boot menu entry for UEFI Firmware Settings ... done "; - /// The real-boot deploy of the tipc patch: everything worked - /// (lab/logs/20261003-154745-real-boot-tui-tipc). + /// A deploy where initramfs generation and GRUB both succeed. const CLEAN_DEPLOY_LOG: &str = "\ * Preparing modules cp: cannot stat '/home/lima.guest/.cache/kw/envs/L29wdC9waC1sYWIvd29yay9saW51eA==/ph-boot/arch/arm64/boot/dts/*.dtb': No such file or directory diff --git a/src/kw/models/readiness.rs b/src/kw/models/readiness.rs index 94e59e5..232eb83 100644 --- a/src/kw/models/readiness.rs +++ b/src/kw/models/readiness.rs @@ -140,7 +140,7 @@ pub enum BootOnceState { Unknown, } -/// Snapshot of tree, kw binary, and history probes used to decide whether +/// Snapshot of tree, kw binary, and history probes that decide whether /// a job can start, and why not. #[derive(Debug, Clone)] pub struct KwReadiness { diff --git a/src/kw/remote.rs b/src/kw/remote.rs index a39b057..767ac02 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -357,8 +357,8 @@ Host dut #[test] fn reordered_fields_and_comments_inside_a_stanza_are_tolerated() { - // kw used to assume Hostname/Port/User on the three lines after - // Host; that broke on comments and reordering. We key-match. + // Hostname, Port, and User are matched by key, not by their + // position on the three lines after Host. let content = "\ #kw-default=origin Host origin diff --git a/src/lore/application/models/cache.rs b/src/lore/application/models/cache.rs index b74d56e..0d25484 100644 --- a/src/lore/application/models/cache.rs +++ b/src/lore/application/models/cache.rs @@ -89,7 +89,7 @@ pub struct LoreCache { pub patchsets: HashMap, } -/// Data returned by `LoreService::warm_bootstrap_cache`, used to initialise +/// Data returned by `LoreService::warm_bootstrap_cache`, which initialises /// `App` without it knowing about persistence paths or network policy. #[derive(Default)] pub struct BootstrapLoreData { diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index 1859415..ee39cf0 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -5,8 +5,6 @@ use crate::lore::domain::patch::{Patch, PatchFeed, PatchRegex}; const LORE_PAGE_SIZE: usize = 200; /// Tracks feed pagination state for a single mailing list target. -/// -/// Replaces the state that was previously mixed into [`LoreSession`]. pub struct PatchFeedIndex { next_offset: usize, representative_patch_ids: Vec, diff --git a/src/main.rs b/src/main.rs index bfaf045..6eecda1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -167,7 +167,7 @@ async fn main() -> Result<()> { // 6. RenderActor — no further requests once App is gone // 7. UiActor — no further scene builds once App is gone // 8. TerminalActor — restores the terminal last so the screen stays usable - // during the steps above + // while the actors above shut down AppActor::spawn( app, terminal_handle.clone(), diff --git a/src/ui/screens/details.rs b/src/ui/screens/details.rs index d3ec832..232d855 100644 --- a/src/ui/screens/details.rs +++ b/src/ui/screens/details.rs @@ -161,7 +161,6 @@ impl DetailsPainter { f.render_widget(patchset_details, details_chunk); - // TODO: Create a function to produce new action lines let patchset_actions = vec![ Line::from(vec![ if scene.is_bookmarked { From a3ee48a10e2153750031576ab133b64b785ba2a0 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 13:59:41 -0300 Subject: [PATCH 50/61] docs: shorten long comments Trim every comment block to five lines or fewer, including module docs. Notes that capture behaviour the code cannot show stay: GNU getopt prefix rules, kw exit codes, the kw O= resolution order, and shutdown ordering. Signed-off-by: lorenzoberts --- src/app/actor.rs | 24 ++---- src/app/dependencies.rs | 10 +-- src/app/flows/kw_ops.rs | 21 ++--- src/app/mod.rs | 24 ++---- src/app/screens/kw_ops.rs | 10 +-- src/app/view_model.rs | 10 +-- src/infrastructure/errors.rs | 17 ++-- src/infrastructure/process/trait.rs | 13 +-- src/infrastructure/terminal.rs | 9 +- src/input/actor.rs | 15 +--- src/input/mod.rs | 9 +- src/kw/actor/actor_test.rs | 12 ++- src/kw/actor/mod.rs | 89 +++++++------------- src/kw/argv.rs | 76 +++++++---------- src/kw/log_scan.rs | 11 ++- src/kw/models/history.rs | 13 ++- src/kw/models/readiness.rs | 19 ++--- src/kw/readiness.rs | 121 ++++++++------------------- src/kw/remote.rs | 29 +++---- src/lore/application/actor.rs | 10 +-- src/lore/application/models/cache.rs | 19 ++--- src/main.rs | 15 +--- src/render/actor.rs | 11 +-- src/render/patch_renderer.rs | 33 ++------ src/terminal/actor.rs | 8 +- src/terminal/mod.rs | 9 +- src/ui/actor.rs | 13 +-- src/ui/scene.rs | 8 +- src/ui/screens/kw_ops.rs | 6 +- 29 files changed, 216 insertions(+), 448 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index ccfec72..eef2870 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -1,14 +1,8 @@ -//! Central orchestration actor: owns [`App`] and drives the main render/input loop. +//! Central orchestration actor: owns `App` and drives the render/input loop. //! -//! Each frame: process system updates → project state to [`AppViewModel`] via -//! `UiHandle` → draw through -//! `TerminalHandle` → await the next -//! `InputEvent`, a kw-status change, or a -//! KwOps log-tail tick while a job is running on that screen. -//! -//! The actor stops when the input event channel closes (user quit) or when I/O -//! returns an unrecoverable error. Startup dependency checks run before this -//! actor is spawned. +//! Each frame projects `AppViewModel`, draws through `TerminalHandle`, then +//! awaits an `InputEvent`, a kw-status change, or a KwOps log tick. It stops +//! when the input channel closes or I/O returns an unrecoverable error. use std::{ops::ControlFlow, time::Duration}; use color_eyre::{eyre::eyre, Report, Result}; @@ -33,13 +27,11 @@ use crate::{ use std::future::pending; use tokio::time; -/// Owns `App` state and drives the main application loop on a dedicated task. -/// -/// Constructed via [`AppActor::spawn`], which moves all owned resources into -/// the actor and returns an [`AppHandle`] to the caller. +/// Owns `App` state and drives the main loop on a dedicated task. /// -/// The actor runs until the input event channel closes (the user requested -/// exit via the normal key binding) or an unrecoverable error occurs. +/// [`AppActor::spawn`] moves owned resources in and returns an [`AppHandle`]. +/// The actor runs until the input channel closes or an unrecoverable error +/// occurs. pub struct AppActor { app: App, terminal_handle: TerminalHandle, diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 1416760..5767908 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -11,13 +11,11 @@ use crate::{ pub(crate) struct DependencyService; impl DependencyService { - /// Verifies required and optional external binaries before the terminal starts. + /// Verifies external binaries before the terminal starts. /// - /// A missing `b4` is a hard failure; all other missing binaries only emit - /// warnings — including `kw`, and including an unverifiable kw version, - /// since kw's own VERSION file is stale upstream (it reports `beta-0.9` - /// even at the 0.10 tag). This keeps fatal startup failures out of - /// terminal raw mode. + /// A missing `b4` is fatal. Other misses, including `kw` and an + /// unverifiable version, only warn: kw's VERSION file reports `beta-0.9` + /// even at the 0.10 tag, and fatal checks must stay out of raw mode. pub(crate) fn check_external_deps( env: &dyn EnvTrait, shell: &dyn ShellTrait, diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 5bdea94..5cace74 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -59,15 +59,11 @@ impl KwStartKind { } impl App { - /// Refresh the KwOps log panel from the current job's log file. - /// - /// A missing file is normal just after accept (`Waiting for kw output…` - /// is projected from an empty tail). Other read errors become a - /// non-fatal diagnostic in the panel and do not change job status. - /// Returns whether the displayed text changed. - /// - /// The read runs on the blocking pool: the log is a real file, and - /// AppActor's loop must not stall on disk I/O. + /// Refresh the KwOps log panel from the job log. A missing file is normal + /// just after accept. Other read errors become a non-fatal panel + /// diagnostic and do not change job status. Returns whether the text + /// changed. The read runs on the blocking pool so the actor loop does + /// not stall on disk I/O. pub(crate) async fn refresh_kw_ops_log_tail(&mut self) -> bool { if self.state.kw.ops.is_none() { return false; @@ -142,10 +138,9 @@ impl App { /// Seed the projection from GetStatus after the watch is gone. /// - /// A successful query lets the poll arm engage if a job is already - /// running (the subscribe-failed-with-live-job corner). A failed query - /// clears status so a dead actor cannot leave a stale "building" - /// indicator. + /// A successful query lets polling engage if a job is already running. + /// A failed query clears status so a dead actor cannot leave a stale + /// "building" indicator. pub(crate) async fn fallback_kw_status(&mut self) { let Some(kw) = self.services.kw.clone() else { self.state.kw.status = None; diff --git a/src/app/mod.rs b/src/app/mod.rs index 4f0491a..d1b6094 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,14 +1,8 @@ -//! Application orchestration: state, screen flows, view-model projection, and the -//! central `AppActor` run loop. +//! Application orchestration: state, screen flows, and the `AppActor` loop. //! -//! [`App`] holds [`AppState`] (navigation, lore UI state, user data, config -//! snapshot) and [`AppServices`] (typed handles to -//! `LoreApiHandle`, -//! `RenderHandle`, plus injected -//! infrastructure traits). Screen-specific input is dispatched from -//! `AppActor` into [`app::flows`]; -//! presentation data crosses the UI boundary only through [`AppViewModel`] via -//! [`App::present`]. +//! `App` holds `AppState` and `AppServices`. Screen input goes from +//! `AppActor` into `flows`. Presentation crosses the UI boundary only as +//! `AppViewModel` via `App::present`. pub(crate) mod actions; pub mod actor; pub(crate) mod dependencies; @@ -100,14 +94,8 @@ pub struct App { } impl App { - /// Creates a new instance of `App`. - /// - /// Configuration starts from the already-bootstrapped snapshot owned by the - /// Config actor. Lore bootstrap uses already-warmed cache from `lore_service`. - /// - /// # Returns - /// - /// `App` instance with loading configurations and app data. + /// Creates an `App` from the Config actor's snapshot and the warmed lore + /// cache. #[expect(clippy::too_many_arguments)] pub fn new( config: ConfigSnapshot, diff --git a/src/app/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index 239ca13..263fe45 100644 --- a/src/app/screens/kw_ops.rs +++ b/src/app/screens/kw_ops.rs @@ -32,13 +32,11 @@ impl KwOpsState { } } - /// Re-open KwOps for the same patchset/tree without dropping extras, - /// an in-flight cancel/start indication, or a boot-once acknowledgement. + /// Re-open KwOps for the same patchset and tree without dropping extras, + /// an in-flight cancel or start, or a boot-once acknowledgement. /// - /// Branch and `head_unreadable` stay as the user last edited them. - /// An external HEAD change while away is not applied, so a stale - /// detached-HEAD flag can survive a tree that has since become - /// readable. + /// Branch and `head_unreadable` stay as last edited. A HEAD change while + /// away is not applied, so a stale detached-HEAD flag can survive. pub fn reenter(&mut self, patchset_title: String, tree: KernelTree, readiness: KwReadiness) { self.patchset_title = patchset_title; self.tree = tree; diff --git a/src/app/view_model.rs b/src/app/view_model.rs index ab50239..85e663f 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -1,11 +1,7 @@ -//! Owned, typed presentation projections. +//! Owned presentation projections built from `AppState` by `App::present`. //! -//! [`AppViewModel`] is built from [`state::AppState`] and handed off -//! through [`App::present`]. -//! -//! These types sit at the *application* layer: they represent what `App` knows -//! about the presentation before `UiCore` translates them into paint-ready -//! `UiScene` nodes. +//! They are what `App` knows about presentation before `UiCore` turns them +//! into paint-ready `UiScene` nodes. use ansi_to_tui::IntoText; diff --git a/src/infrastructure/errors.rs b/src/infrastructure/errors.rs index d28b413..25b0f1d 100644 --- a/src/infrastructure/errors.rs +++ b/src/infrastructure/errors.rs @@ -46,18 +46,11 @@ impl TerminalRestoreGuard { } } -/// Installs the process panic and eyre hooks. -/// -/// The panic hook restores the terminal, then color_eyre prints the panic. -/// The eyre hook only forwards to color_eyre. eyre runs that hook for every -/// report construction, including errors the application handles and keeps -/// running after, so it must not restore the terminal. -/// -/// `main` returns [`eyre::Result`], and the runtime prints a fatal -/// report after `main` returns. [`TerminalRestoreGuard::restore_once`] runs on -/// that path first. -/// Normal shutdown restores through -/// [`TerminalHandle::shutdown`]. +/// Installs the panic and eyre hooks. The panic hook restores the terminal, +/// then color_eyre prints. The eyre hook only forwards to color_eyre: it +/// runs for every report, including handled errors, so it must not restore. +/// Fatal `main` errors call `TerminalRestoreGuard::restore_once` before the +/// report prints; normal shutdown uses `TerminalHandle::shutdown`. pub fn install_hooks() -> Result<()> { let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); diff --git a/src/infrastructure/process/trait.rs b/src/infrastructure/process/trait.rs index 5ce8070..4bcde99 100644 --- a/src/infrastructure/process/trait.rs +++ b/src/infrastructure/process/trait.rs @@ -35,15 +35,10 @@ pub trait RunningProcess: Send { async fn wait(&mut self) -> Result; /// Send SIGTERM to the whole process group, not just the direct child. - /// Idempotent: an already-gone group (ESRCH) is reported as success. - /// Signaling a leaderless group is intentional — it is how grandchildren - /// that outlive the leader get cleaned up — but in the narrow window where - /// the kernel has recycled the pgid, the signal could land on an unrelated - /// process group. - /// - /// Both methods take `&mut self`: a consumer that waits while staying able - /// to cancel should `tokio::select!` between `wait()` and its cancel - /// signal, then call `kill()`. + /// An already-gone group (ESRCH) is success. A leaderless group is + /// signaled on purpose so grandchildren die, but a recycled pgid can + /// hit an unrelated group. Both methods take `&mut self`: `select!` + /// between `wait()` and cancel, then `kill()`. fn kill(&mut self) -> Result<(), ProcessError>; /// Send SIGKILL to the whole process group. The escalation rung for a diff --git a/src/infrastructure/terminal.rs b/src/infrastructure/terminal.rs index 7e0f311..75f09d9 100644 --- a/src/infrastructure/terminal.rs +++ b/src/infrastructure/terminal.rs @@ -1,9 +1,8 @@ -//! Low-level Crossterm/Ratatui helpers for the terminal actor session and -//! emergency restore hooks. +//! Crossterm/Ratatui helpers for the terminal session and emergency restore. //! -//! Normal runtime startup uses [`init`] from `main`, session operations go -//! through [`session::CrosstermTerminalSession`], and the -//! panic hook plus `main`'s fatal-error path call [`restore`] directly. +//! `init` starts the session from `main`. Operations go through +//! `CrosstermTerminalSession`. The panic hook and `main`'s fatal path call +//! `restore` directly. use ratatui::{ crossterm::{ diff --git a/src/input/actor.rs b/src/input/actor.rs index 2b59061..efcf46e 100644 --- a/src/input/actor.rs +++ b/src/input/actor.rs @@ -1,15 +1,8 @@ -//! Input mediation actor: polls the terminal and maps raw events to semantic -//! `InputEvent` values for the application. +//! Input actor: polls the terminal and maps events to `InputEvent`. //! -//! A dedicated pump subtask calls -//! `TerminalHandle::poll_event` -//! so an in-flight poll is never abandoned when a control message wins the -//! select race. Mapped events are delivered to the subscriber channel -//! registered via -//! `InputHandle::subscribe_app`; -//! context updates from -//! `InputHandle::update_context` -//! change key bindings without restarting the pump. +//! A pump task calls `TerminalHandle::poll_event` so an in-flight poll is +//! not dropped when a control message wins the select. Events go to the +//! `subscribe_app` channel; `update_context` changes bindings without a restart. use std::time::Duration; use tokio::{select, spawn, sync::mpsc}; diff --git a/src/input/mod.rs b/src/input/mod.rs index ceee4aa..961f900 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,11 +1,8 @@ //! Protocol boundary between terminal input and application intent. //! -//! The Input actor mediates between the Terminal actor (producer of raw -//! [`event::TerminalEvent`] values) and the App (consumer of semantic -//! [`event::InputEvent`] values). [`mapper::InputMapper`] translates events -//! using the current [`context::InputContext`], which the App updates after -//! every state mutation. [`handle::InputHandle`] is the cloneable public -//! interface to the actor. +//! The Input actor turns `TerminalEvent` values into `InputEvent` values +//! via `InputMapper` and the App's `InputContext`. `InputHandle` is the +//! cloneable interface. pub mod actor; pub mod bindings; diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index b8a16f1..fa90783 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -140,13 +140,11 @@ fn recording_shell( (shell, calls) } -/// A stateful shell double for the checkout/restore tests: kw's -/// version probe answers 0.10.0, `git status --porcelain` reflects the -/// dirty flag, the HEAD probe reports `head` (with the trailing -/// newline git prints), and a successful `git switch` updates `head` -/// — mirroring a real worktree the actor switches between branches. -/// Switches to `fail_switch_to` fail, so a test can model "going -/// forward works, coming back fails". +/// Shell double for checkout/restore tests: kw version probe answers +/// 0.10.0, `git status --porcelain` reflects the dirty flag, the HEAD +/// probe reports `head` (including git's trailing newline), and a +/// successful `git switch` updates `head`. Switches to `fail_switch_to` +/// fail, so going forward can succeed while coming back fails. pub(super) struct GitStub { head: Arc>, dirty: Arc, diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index a7beab4..2898f4e 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -1,16 +1,8 @@ -//! kw actor: owns kw build/deploy job state and the kw history store. +//! kw actor: owns build/deploy job state and the history store. //! -//! All kw operations go through `KwHandle` as -//! typed request/reply messages. `Start*` messages reply immediately with an -//! accept/refuse verdict — a job accepted by the actor keeps running after -//! the caller has been answered, so the AppActor loop never blocks on a -//! kernel build. The actor composes the readiness probes from -//! [`kw::readiness`] and records applies through the shared -//! `KwHistoryStore`. -//! -//! Git checkout and readiness probes run on the blocking pool. -//! `create_dir_all`, history writes, and completion probes run inline -//! on the actor task. +//! `Start*` replies immediately with accept or refuse; an accepted job +//! keeps running so AppActor never blocks on a kernel build. Git checkout +//! and readiness probes run on the blocking pool; history writes run inline. use std::{ ops::ControlFlow, @@ -254,11 +246,9 @@ impl KwActor { KwMessage::Shutdown { reply } => { // Quit-prompt cooperation: the app asks whether a job is // running before quitting; if it quits anyway, the job's - // process group is killed here. The log may capture partial - // output and the tree a partial build. The reply is held - // until the kill escalation has run its (bounded) course, - // so a caller tearing down the runtime afterwards cannot - // leave orphaned kw processes behind. + // process group is killed here. The reply is held until the + // kill escalation finishes, so teardown cannot leave orphaned + // kw processes behind. if self.job.is_some() { tracing::info!("killing kw job process group during shutdown"); self.request_cancel().ok(); @@ -293,12 +283,11 @@ impl KwActor { } let tree_path = PathBuf::from(request.tree.path()); - // Hard fail on invoke: with no kw binary on PATH no job can run. - // The version check is advisory only — kw's shipped VERSION file - // is stale, so Below/Unknown are logged, never gated. - // Probes run on the blocking pool so a slow tree or cold - // `kw --version` cannot stall the Tokio worker (or delay Cancel - // past this Start's await). + // Hard fail when kw is not on PATH: no job can run. The version + // check is advisory only — kw's VERSION file is stale — so + // Below/Unknown are logged, never gated. Probes run on the + // blocking pool so a slow tree or cold `kw --version` cannot stall + // the worker or delay Cancel past this Start's await. let fs = Arc::clone(&self.fs); let env = Arc::clone(&self.env); let shell = Arc::clone(&self.shell); @@ -430,13 +419,11 @@ impl KwActor { Ok(()) } - /// Deploy-kind gates after the branch switch: resolved remote for - /// every deploy kind; the deploy-alone record match only for - /// StartDeploy, against the requested (now checked-out) branch; - /// boot-once confirm last so a missing record refuses before the - /// confirm popup. Any refusal is rolled back by the caller. - /// BuildThenDeploy skips the record gate because the build has not - /// run yet. Probes run on the blocking pool. + /// After the branch switch: a resolved remote for every deploy kind; the + /// deploy-alone record match only for StartDeploy, on the checked-out + /// branch; boot-once confirm last so a missing record refuses first. + /// BuildThenDeploy skips the record gate (the build has not run). + /// Refusals roll back. Probes run on the blocking pool. async fn prepare_deploy( &self, kind: KwJobKind, @@ -468,15 +455,11 @@ impl KwActor { .map_err(|error| KwStartError::GitStateProbe(error.to_string()))? } - /// Refuse a dirty worktree, record HEAD, then `git switch` to the - /// requested branch. The git calls run on the blocking pool so the - /// accept reply stays immediate. - /// - /// The HEAD probe sits between the dirty check and the switch: it - /// must capture the branch the user was on, or RestorePreviousBranch + /// Refuse a dirty worktree, record HEAD, then `git switch`. Git runs on + /// the blocking pool so the accept reply stays immediate. HEAD is probed + /// between the dirty check and the switch, or RestorePreviousBranch /// would restore the branch the job switched to. An unprobed HEAD - /// (detached, or not a git repo) yields `None` rather than a wrong - /// branch. + /// (detached, or not a git repo) is `None`, not a wrong branch. async fn checkout_build_branch( &self, request: &StartRequest, @@ -782,13 +765,10 @@ impl KwActor { JobOutcome::Cancelled => return None, }; - // The record describes the accept-time snapshot: the build ran - // under this tree path, output dir, and arch. Re-resolving them - // here could describe a configuration the build never used — an - // env deactivated mid-build would key the record with a wrong - // `output_dir: None` and probe the tree for an image the build - // wrote under O=, a Frankenstein match for a later deploy-alone - // probe. + // The record is the accept-time snapshot: tree path, output dir, and + // arch the build actually used. Re-resolving them could describe a + // later configuration — an env deactivated mid-build would store + // `output_dir: None` and look for an image the build wrote under O=. let tree_path = Path::new(&job.tree_path); let build_root = job.output_dir.as_deref().unwrap_or(tree_path); // A failed build may have left a stale image from an earlier @@ -1080,20 +1060,11 @@ impl KwActor { } } - /// Maps a reap result observed after a cancel request. A signal-terminated - /// process means our SIGTERM/SIGKILL landed — the job was really cancelled. - /// A plain exit means the process finished on its own before the signal: - /// report the real outcome, because a cancel must not mask a failure the - /// build history (and deploy-alone readiness) needs to see. - /// - /// Known, accepted edges: a process that *traps* our SIGTERM and exits 0 - /// counts as success for a standalone build (kw is bash, so this is - /// possible in principle). BuildThenDeploy does not chain that exit into - /// deploy — see [`Self::rewrite_outcome_after_building_cancel`]. An external signal - /// racing a cancel (e.g. the OOM killer) reads as Cancelled. Those cases - /// are indistinguishable from the honest ones without comparing who - /// signaled first, and both favor showing the user real output over - /// inventing failures. + /// After cancel: signal death means we cancelled; a plain exit finished + /// on its own and is reported, so cancel cannot mask a failure that + /// history and deploy-alone must see. A trapped SIGTERM that exits 0 + /// counts as success (BuildThenDeploy still does not chain into deploy). + /// An external signal racing the cancel reads as Cancelled. fn map_outcome_after_cancel(result: Result) -> JobOutcome { use std::os::unix::process::ExitStatusExt; diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 8faf016..f0cafad 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -39,20 +39,11 @@ impl ReservedOption { } } -/// Reserved for `kw build`: patch-hub owns the job's log file -/// (`ProcessTrait` captures kw's stdout/stderr to it) — a user-supplied -/// `--save-log-to` would split stdout/stderr away from the job log — and -/// never runs `--menu` from automation: the job's stdio is a log file, so -/// menuconfig would hang until cancelled. -/// -/// `--alert` is stripped from extras (and not injected on the base argv): -/// kw beta-0.9 (still what many installs report, including this lab) treats -/// unrecognized options as hard failures (`Invalid option`), and the -/// unattended default is already `alert=n` in kw's own config. -/// -/// `--clean` / `--full-cleanup` can wipe the tree, `--menu` / `--doc` / -/// `--info` hang a redirected job, and `--from-sha` mutates git. They are -/// stripped from extras and never injected. +/// Reserved for `kw build`. Patch-hub owns the job log, so `--save-log-to` +/// is stripped. `--menu` is stripped and never injected: stdio is a log, so +/// menuconfig would hang. `--alert` is stripped, not injected: unknown +/// options fail hard, and the unattended default is already `alert=n`. +/// `--clean`, `--full-cleanup`, `--doc`, `--info`, and `--from-sha` too. const BUILD_RESERVED: &[ReservedOption] = &[ ReservedOption::gnu_abbrev(&["--help", "-h"], false), ReservedOption::gnu_abbrev(&["--alert"], true), @@ -65,15 +56,11 @@ const BUILD_RESERVED: &[ReservedOption] = &[ ReservedOption::gnu_abbrev(&["--from-sha"], true), ]; -/// Reserved for `kw deploy`. Injected `--remote` / reboot / force win. -/// User extras that override those, switch to local, list/uninstall, -/// run `--setup`, or pass `-n` are stripped. Build-only extras are -/// stripped too (one KwOps field; unknown flags are exit 22). -/// GNU getopt forms (`-rf`, `-Fpkg`, unique `--boot`) are stripped as well. -/// -/// `-l` is `--list`, not `--local`; `-r` is `--reboot`, not `--remote`. -/// `--uninstall`/`-u` is reserved as a boolean so `-u` does not eat the -/// next token. `--alert` is not a deploy option. +/// Reserved for `kw deploy`. Injected `--remote`, reboot, and force win. +/// Extras that override those, go local, list/uninstall, `--setup`, or `-n` +/// are stripped, plus build-only extras (unknown flags exit 22) and GNU +/// getopt (`-rf`, `-Fpkg`, unique `--boot`). `-l` is `--list` not `--local`; +/// `-r` is `--reboot` not `--remote`; `-u` takes no value; `--alert` is not. const DEPLOY_RESERVED: &[ReservedOption] = &[ ReservedOption::gnu_abbrev(&["--remote"], true), ReservedOption::gnu_abbrev(&["--local"], false), @@ -115,12 +102,11 @@ impl KwArgvService { Self::merge_extra_args(&["build"], BUILD_RESERVED, extra_args) } - /// The argv for a deploy job: `kw deploy --remote - /// --no-reboot|--reboot [--force] `. Reserved extras are - /// stripped so the injected remote, reboot, and force flags win, and - /// so build-only extras (shared KwOps field) cannot fail kw deploy's - /// getopt. `--force` is omitted entirely when `force` is false rather - /// than passing a no-op, because kw has no `--no-force`. + /// Deploy argv: `kw deploy --remote --no-reboot|--reboot + /// [--force] `. Reserved extras are stripped so the injected + /// remote, reboot, and force flags win, and build-only extras cannot + /// fail deploy's getopt. `--force` is omitted when `force` is false: + /// kw has no `--no-force`. pub fn build_deploy_argv( endpoint: &str, reboot: bool, @@ -139,13 +125,11 @@ impl KwArgvService { Self::merge_extra_args(&base, DEPLOY_RESERVED, extra_args) } - /// Appends user-supplied extra args to `base`, stripping every token that - /// would override a reserved option. GNU getopt forms are stripped too: + /// Appends extra args to `base` in order, stripping tokens that would + /// override a reserved option. GNU getopt forms are stripped too: /// `--name=value`, `--name value`, unique long abbreviations, bundled - /// shorts (`-rf`), and attached short values (`-Fpkg.kw.tar`). A mixed - /// short cluster that contains any reserved flag is dropped whole, so - /// `-uVALUE` cannot be rewritten into a leftover `-VALUE`. All other - /// extras pass through in order. + /// shorts (`-rf`), and attached values (`-Fpkg`). A mixed short cluster + /// containing any reserved flag is dropped whole, not rewritten. pub fn merge_extra_args( base: &[&str], reserved: &[ReservedOption], @@ -172,12 +156,11 @@ impl KwArgvService { } impl KwArgvService { - /// Finds the reserved option a token sets, if any: an exact spelling, - /// `--name=value` for a long spelling, a unique GNU getopt abbreviation - /// of a `match_abbrev` long, a bundled reserved short, or an attached - /// short value. The `=` boundary keeps `--alertness` from matching - /// `--alert`; an abbreviation must be a prefix of the reserved spelling, - /// not the other way around. + /// The reserved option a token sets, if any: an exact spelling, + /// `--name=value`, a unique GNU getopt abbreviation of a `match_abbrev` + /// long, a bundled reserved short, or an attached short value. The `=` + /// boundary keeps `--alertness` from matching `--alert`. An abbreviation + /// must be a prefix of the reserved spelling, not the reverse. fn find_reserved_option<'a>( reserved: &'a [ReservedOption], token: &str, @@ -204,12 +187,11 @@ impl KwArgvService { .or_else(|| Self::find_unique_long_abbrev(reserved, name)) } - /// GNU getopt unique-prefix match among options that opted into - /// `match_abbrev`. Uniqueness is against this reserved table, not kw's - /// full option list — a prefix kw would reject as ambiguous can still - /// strip here when only one reserved long matches. Ambiguous prefixes - /// among reserved longs are left alone so a following value token is - /// not eaten. + /// GNU getopt unique-prefix match among options with `match_abbrev`. + /// Uniqueness is against this reserved table, not kw's full option list: + /// a prefix kw would call ambiguous can still strip here when only one + /// reserved long matches. Ambiguous prefixes among reserved longs are + /// left alone so a following value token is not eaten. fn find_unique_long_abbrev<'a>( reserved: &'a [ReservedOption], name: &str, diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index 3c59454..6d665b0 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -22,12 +22,11 @@ const DEPLOY_WARNING_MARKERS: [&str; 4] = [ pub struct LogScanService; impl LogScanService { - /// The first line of a failed job's log that names what went wrong: a - /// compiler/linker `error:`, a modpost `ERROR:`, a kbuild `*** ...` - /// banner (e.g. "The source tree is not clean"), or a git/kw `error:`. - /// Falls back to the last `make: *** ... Error N` line, which only names - /// the failing target. Kbuild prints the real cause first and a cascade - /// of make errors after it, so the first match is the useful one. + /// The first line of a failed job's log that names the cause: a compiler + /// or linker `error:`, a modpost `ERROR:`, a kbuild `***` banner, or a + /// git/kw `error:`. Otherwise the last `make: *** Error N` line, which + /// only names the target. Kbuild prints the cause first and a cascade of + /// make errors after, so the first match is the useful one. pub fn find_first_error(log: &str) -> Option { log.lines() .map(str::trim) diff --git a/src/kw/models/history.rs b/src/kw/models/history.rs index 25446d4..2634073 100644 --- a/src/kw/models/history.rs +++ b/src/kw/models/history.rs @@ -13,14 +13,11 @@ pub struct KwApplyRecord { pub applied_at: String, } -/// One recorded `kw build` attempt on a kernel tree branch, whether it -/// succeeded or not. Failed attempts are stored so deploy-alone can refuse -/// them instead of treating the tree as never built. -/// -/// `message_id`, `arch`, `image_path`, and `kernelrelease` are optional: a -/// build can target a branch no patchset was applied to, `arch` is unknown -/// when image discovery had to glob, and a failed build may never have -/// produced an image or a kernelrelease. +/// One recorded `kw build` on a tree branch, success or failure. Failures +/// are stored so deploy-alone can refuse them. `message_id`, `arch`, +/// `image_path`, and `kernelrelease` are optional: the branch may have no +/// patchset, `arch` is unknown when discovery globs, and a failed build may +/// lack an image or kernelrelease. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct KwBuildRecord { pub kernel_tree_id: String, diff --git a/src/kw/models/readiness.rs b/src/kw/models/readiness.rs index 232eb83..32d7cf1 100644 --- a/src/kw/models/readiness.rs +++ b/src/kw/models/readiness.rs @@ -42,12 +42,9 @@ pub enum TreeReadiness { InTreeBuildArtifacts, } -/// Result of comparing the version kw reports against [`KW_MIN_VERSION`]. -/// -/// Advisory only: kw's shipped VERSION file is stale (it reads `beta-0.9` -/// even at the 0.10 tag), so `Below` can fire on a genuinely recent kw and -/// must never gate functionality — the raw line is carried verbatim so the -/// UI can show exactly what kw reported. +/// kw's reported version compared with `KW_MIN_VERSION`. Advisory only: +/// kw's VERSION file reads `beta-0.9` even at the 0.10 tag, so `Below` must +/// never gate. The raw line is kept so the UI can show what kw reported. #[derive(Debug, Clone, PartialEq, Eq)] pub enum KwVersionCheck { Meets, @@ -126,13 +123,11 @@ pub enum DeployAloneRefusal { ImageMissing, } -/// Whether `.kw/deploy.config` (then the user-level copy) sets -/// `boot_into_new_kernel_once=no`. +/// Whether deploy config sets `boot_into_new_kernel_once=no`. /// -/// kw only treats the literal value `no` as off (`src/deploy.sh`); any other -/// value, including a missing key, leaves the option on. [`Unknown`] is -/// therefore a confirm-to-proceed gate, same as [`On`]: patch-hub cannot -/// pass a CLI off-switch. +/// kw treats only the literal `no` as off. Any other value, including a +/// missing key, leaves the option on, so `Unknown` gates like `On`: +/// patch-hub cannot pass a CLI off-switch. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BootOnceState { Off, diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index d65adf2..86c84ff 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -1,15 +1,8 @@ -//! Readiness probes for running `kw build` / `kw deploy` on a configured -//! kernel tree. +//! Readiness probes for `kw build` / `kw deploy` on a configured kernel tree. //! -//! Most probes mirror the corresponding discovery logic in kw itself -//! (`src/lib/kwlib.sh`, `src/lib/kw_config_loader.sh`, `src/deploy.sh` at -//! kw 0.10) so patch-hub's idea of "ready" matches what kw will actually -//! do, instead of being a parallel interpretation that can silently drift -//! from it. The deliberate divergences — the `arch`-unset glob fallback -//! and the non-recursive boot-dir scan — are documented on -//! [`ReadinessService::find_newest_kernel_image`]. The probes are pure functions over -//! injected infrastructure traits; KwActor composes them into the -//! `GetReadiness` snapshot. +//! Probes follow kw's own discovery so "ready" matches what kw will do. +//! Divergences — the `arch`-unset glob and the non-recursive boot-dir scan — +//! are documented on `ReadinessService::find_newest_kernel_image`. use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; @@ -74,12 +67,11 @@ impl ReadinessService { } } - /// Parses kw's `key=value` config format (`.kw/build.config`, - /// `.kw/deploy.config`, ...), mirroring kw's own `parse_configuration`: - /// blank lines and lines starting with `#` are skipped, everything from the - /// last `#` on is stripped as a trailing comment, the key has all - /// whitespace removed, and the value is trimmed. Lines without `=` are - /// ignored, and a final line without a trailing newline still counts. + /// Parses kw's `key=value` config, like kw's `parse_configuration`: blank + /// lines and lines starting with `#` are skipped, text from the last `#` + /// is a trailing comment, the key loses all whitespace, and the value is + /// trimmed. Lines without `=` are ignored. A final line without a + /// trailing newline still counts. pub fn parse_kw_config(content: &str) -> HashMap { let mut entries = HashMap::new(); for line in content.lines() { @@ -156,13 +148,11 @@ impl ReadinessService { } } - /// Reads the literal `arch=` value from `/.kw/build.config`, the same - /// value kw's image discovery globs under `arch//boot/`. Returns - /// `None` when the file or the key is absent or empty — kw's - /// `${build_config[arch]:-...}` expansion treats empty as unset — meaning - /// the caller falls back to globbing `arch/*/boot/`, a deliberate - /// divergence from kw's merged-config fallback (see - /// [`ReadinessService::find_newest_kernel_image`]). + /// The literal `arch=` value from `/.kw/build.config`, the same + /// value kw globs under `arch//boot/`. `None` when the file or key + /// is absent or empty — kw treats empty as unset — so the caller globs + /// `arch/*/boot/` instead of kw's merged-config fallback (see + /// `ReadinessService::find_newest_kernel_image`). pub fn read_build_arch(fs: &dyn FileSystemTrait, tree_path: &Path) -> Option { let content = fs .read_to_string(&tree_path.join(".kw").join("build.config")) @@ -175,26 +165,11 @@ impl ReadinessService { } } - /// Resolves kw's active build output dir (`O=`) for `tree_path`, mirroring - /// kw's env handling: the env name is the content of - /// `/.kw/env.current` (trailing newlines stripped, like bash's - /// `$(< ...)`), and the output dir is - /// `{XDG_CACHE_HOME | ~/.cache}/kw/envs//`. - /// The tree path is encoded like kw 0.10's `get_encoded_pwd` — standard - /// base64 with padding, no wrapping — after trimming trailing slashes, - /// since kw encodes `$PWD` after changing into the tree. The encoded path - /// may contain `/` (standard alphabet), producing nested directories; kw - /// has the same behavior. - /// - /// kw's launcher recomputes the cache dir unconditionally, so a - /// user-exported `KW_CACHE_DIR` is intentionally ignored here too. The - /// `KWORKFLOW` rename knob is not honored: it exists for kw development. - /// - /// Returns `Ok(None)` when no env is active. The resolved dir is not - /// required to exist: kw/make create it on first build. An unreadable - /// `env.current` or an unresolvable cache base (neither `XDG_CACHE_HOME` - /// nor `HOME` set) is an error, since the env state is then unknown — - /// kw's "active but unresolvable" case. + /// Resolves kw's `O=` dir. Env: `/.kw/env.current`, trailing newlines stripped. + /// Dir: `{XDG_CACHE_HOME|~/.cache}/kw/envs//`, padded base64, + /// no wrap, trailing slashes trimmed (kw 0.10 `get_encoded_pwd`; `/` nests dirs). + /// Ignores `KW_CACHE_DIR` and `KWORKFLOW`. `Ok(None)` if no env is active. + /// Unreadable `env.current` or no cache base is an error. The dir need not exist. pub fn resolve_output_dir( fs: &dyn FileSystemTrait, env: &dyn EnvTrait, @@ -240,29 +215,11 @@ impl ReadinessService { )) } - /// Finds the newest kernel image under `/arch/`. Candidate - /// basenames must end with `Image` (the `-name '*Image'` in kw's - /// `get_kernel_binary_name` is case-sensitive, so `Image.gz` and `image` - /// are excluded) and the most recently modified one wins, with ties broken - /// by descending path (kw's `sort -r | head -1`). - /// - /// With `arch`, only `arch//boot/` is probed — exactly kw's behavior. - /// Without `arch` this is a **deliberate divergence**, not a mirror: kw - /// falls back to the merged kw-config `arch` (packaged default `x86_64`, a - /// directory that does not exist in kernel trees, so `kw deploy` then fails - /// with exit 125), and patch-hub does not read kw's global config layers. - /// Globbing every `arch/*/boot/` gives a more useful readiness signal than - /// probing a directory that is never there — at the cost of possibly - /// reporting an image kw would not find. A green image probe with `arch=` - /// unset is therefore not a guarantee kw deploy will locate one; setting - /// `arch=` in `.kw/build.config` makes the two agree. - /// - /// Second deliberate deviation: kw's `find` recurses into boot/ - /// subdirectories, while this scans only the top level. Kernel images for - /// every arch kw supports are produced directly in boot/ (subdirs like - /// compressed/ or dts/ never hold `*Image` files), and find does not - /// descend into symlinked dirs either, so the behaviors agree on real - /// trees. + /// Newest `*Image` (case-sensitive basename suffix) under + /// `/arch/`; newest mtime wins, path-descending ties. + /// `arch` set: only `arch//boot/`, as kw does. `arch` unset: glob + /// every `arch/*/boot/` top level — kw would use merged-config `x86_64` + /// (absent in trees) and fail — so a hit here is not a deploy guarantee. pub fn find_newest_kernel_image( fs: &dyn FileSystemTrait, build_root: &Path, @@ -337,20 +294,11 @@ impl ReadinessService { } } - /// Deploy-alone readiness gate: a deploy without a preceding build is only - /// allowed when a successful build record exists for the tree and the - /// lookup branch, written against the same tree path and kw env, and a - /// kernel image is still discoverable. - /// - /// `record` is the lookup keyed by the deploy target branch. `latest` is - /// the newest record for the tree across branches. When the target has - /// no keyed record but another branch does, that is - /// [`DeployAloneRefusal::HeadMismatch`], not "no build recorded". - /// - /// This is only the record-matching half of the gate — it says nothing - /// about the tree's *current* state. [`ReadinessService::evaluate_readiness`] conjoins - /// [`TreeReadiness`] into its `deploy_alone` verdict; prefer it over - /// calling this directly. + /// Deploy-alone record gate: allowed only when a successful build record + /// exists for the tree and lookup branch, written against the same tree + /// path and kw env, and an image is still discoverable. A record on + /// another branch is `HeadMismatch`, not "no build recorded". Tree + /// readiness is conjoined later by `evaluate_readiness`. pub fn check_deploy_alone( record: Option<&KwBuildRecord>, latest: Option<&KwBuildRecord>, @@ -400,13 +348,10 @@ impl ReadinessService { Ok(()) } - /// Runs all readiness probes for `tree` and composes them into a - /// [`KwReadiness`] snapshot. `head_branch` is the tree's current branch — - /// resolving it (via git) is the caller's job, keeping these probes pure. - /// - /// `for_branch`, when set, is the branch deploy-alone should be judged - /// against (the branch typed on KwOps). `current_branch` still reports - /// the real HEAD so the UI can show both. + /// All readiness probes for `tree`, composed into a `KwReadiness`. + /// `head_branch` is the current branch; the caller resolves it via git. + /// `for_branch`, when set, is the branch deploy-alone is judged against. + /// `current_branch` still reports the real HEAD so the UI can show both. #[expect(clippy::too_many_arguments)] pub fn evaluate_readiness( fs: &dyn FileSystemTrait, diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 767ac02..8395098 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -1,16 +1,8 @@ -//! Resolve the remote kw deploy target from kw's `remote.config`. -//! -//! The file is a small ssh-config-like text file written by `kw remote` -//! (`src/kw_remote.sh` at kw 0.10): an optional `#kw-default=` -//! sentinel plus `Host` stanzas with `Hostname` / `Port` / `User`. This -//! parser reads that file directly rather than scraping `kw remote --list`, -//! whose output is colorized terminal prose with no machine-readable mode. -//! -//! Resolution prefers `/.kw/remote.config`, then -//! `${XDG_CONFIG_HOME:-$HOME/.config}/kw/remote.config`. A present local -//! file is authoritative even when empty or unreadable — falling through -//! to the home copy would silently deploy to a different machine than the -//! tree is configured for. +//! Resolve the kw deploy remote by reading `remote.config`, not `kw remote +//! --list` (colorized prose). Optional `#kw-default=` plus `Host` +//! stanzas with `Hostname` / `Port` / `User`. Prefer +//! `/.kw/remote.config`, then `${XDG_CONFIG_HOME:-$HOME/.config}/kw`. +//! A present local file wins even when empty or unreadable. use std::path::{Path, PathBuf}; @@ -54,12 +46,11 @@ impl HostBuilder { pub struct RemoteConfigService; impl RemoteConfigService { - /// Parses kw's ssh-config-like `remote.config`. Blank lines and comments - /// are skipped; `#kw-default=` (anywhere) names the default Host, - /// last occurrence winning; `Host ` starts a stanza; `Hostname`, - /// `Port`, and `User` are read case-insensitively; `IdentityFile` and - /// other keys are tolerated and ignored. Port defaults to 22 when unset. - /// Duplicate Host names keep the last complete stanza. + /// Parses `remote.config`. Skips blanks and comments. `#kw-default=` + /// (anywhere, last wins) names the default Host. `Host ` starts a + /// stanza; `Hostname`, `Port`, and `User` are case-insensitive; other keys + /// are ignored. Port defaults to 22. Duplicate Host names keep the last + /// complete stanza. pub fn parse_remote_config(content: &str) -> ParsedRemoteConfig { let mut parsed = ParsedRemoteConfig::default(); let mut current: Option = None; diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index 579ca7b..b5cc6d8 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -1,10 +1,8 @@ -//! Lore domain actor: serializes access to [`LoreService`] on a dedicated task. +//! Lore actor: serializes `LoreService` on a dedicated task. //! -//! All lore I/O (mailing lists, feed pages, patchset details, bookmarks, -//! reviewed state, git reply preparation) goes through -//! `LoreApiHandle` as typed -//! request/reply messages. Heavy work runs on a blocking thread pool via -//! [`LoreApiActor::with_core`]; callers never touch [`LoreService`] directly. +//! Mailing lists, feeds, patchset details, bookmarks, reviewed state, and +//! git reply preparation go through `LoreApiHandle`. Heavy work runs on a +//! blocking pool via `LoreApiActor::with_core`. use std::ops::ControlFlow; use tokio::{spawn, sync::mpsc, task}; diff --git a/src/lore/application/models/cache.rs b/src/lore/application/models/cache.rs index 0d25484..691a9cd 100644 --- a/src/lore/application/models/cache.rs +++ b/src/lore/application/models/cache.rs @@ -10,13 +10,9 @@ use crate::lore::{ /// Caller intent for a cache-backed `LoreService` operation. /// -/// * `UseCache` — return in-memory data if present and not stale; fall back to -/// disk; return an error/empty if neither is available. Never hits the network -/// unless the data is genuinely missing. -/// * `Refresh` — discard any cached data and unconditionally fetch from the -/// network, then persist the result. -/// * `Bypass` — fetch from the network and return the result without reading -/// or writing the in-memory cache. +/// `UseCache` returns fresh memory, else disk, else empty, and hits the +/// network only when data is missing. `Refresh` discards the cache, fetches, +/// and persists. `Bypass` fetches and does not touch the in-memory cache. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CacheMode { UseCache, @@ -25,12 +21,9 @@ pub enum CacheMode { Bypass, } -/// Per-data-type TTL configuration injected into `LoreService`. -/// -/// Defaults are chosen conservatively: -/// * mailing lists change rarely → 24 h -/// * feed pages are refreshed by the user explicitly → 1 h -/// * patchsets are immutable historical artefacts → never expire +/// Per-data-type TTL injected into `LoreService`. Defaults: mailing lists +/// 24 h (they change rarely), feed pages 1 h (the user refreshes them), +/// patchsets never expire (they are immutable). pub struct CacheTtl { pub mailing_lists: Duration, pub feed: Duration, diff --git a/src/main.rs b/src/main.rs index 6eecda1..b7920ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -157,17 +157,10 @@ async fn main() -> Result<()> { .map_err(|e| eyre!("{e}"))?; let input_shutdown_handle = input_handle.clone(); - // Shutdown ordering: - // 1. AppActor — exits when the user quits (input channel closes) - // 2. InputActor — no further terminal input is needed once App is gone - // 3. KwActor — no further kw requests once App is gone; kills any - // running job's process group before stopping - // 4. ConfigActor — no further configuration requests once App is gone - // 5. LoreApiActor — no further requests once App is gone - // 6. RenderActor — no further requests once App is gone - // 7. UiActor — no further scene builds once App is gone - // 8. TerminalActor — restores the terminal last so the screen stays usable - // while the actors above shut down + // Shutdown order: AppActor (quit closes input), InputActor, KwActor + // (kills the job's process group), ConfigActor, LoreApiActor, + // RenderActor, UiActor, then TerminalActor last so the screen stays + // usable until the others have stopped. AppActor::spawn( app, terminal_handle.clone(), diff --git a/src/render/actor.rs b/src/render/actor.rs index 6c6b04b..868808a 100644 --- a/src/render/actor.rs +++ b/src/render/actor.rs @@ -1,11 +1,8 @@ -//! Render actor: serializes patch/cover preview rendering on a dedicated task. +//! Render actor: serializes patch and cover preview rendering. //! -//! `RenderHandle::render_patchset_preview` -//! sends a `RenderMessage` to this -//! actor, which delegates to a `RenderServiceApi` -//! implementation (typically `ShellRenderService`) -//! on a blocking thread pool. Keeps shell subprocess work off the async runtime -//! and the UI thread. +//! `RenderHandle::render_patchset_preview` sends a `RenderMessage`. The +//! actor runs a `RenderServiceApi` (typically `ShellRenderService`) on a +//! blocking pool, off the async runtime and the UI thread. use std::ops::ControlFlow; use tokio::{spawn, sync::mpsc, task}; diff --git a/src/render/patch_renderer.rs b/src/render/patch_renderer.rs index 43cf90c..73c360a 100644 --- a/src/render/patch_renderer.rs +++ b/src/render/patch_renderer.rs @@ -39,15 +39,8 @@ impl PatchRendererService { } } - /// Renders a patch using the `bat` command line tool. - /// - /// # Errors - /// - /// If bat isn't installed or if the command fails, an error will be returned. - /// - /// # Tests - /// - /// [tests::test_bat_patch_renderer] + /// Renders a patch with `bat`. Returns an error when bat is missing or + /// the command fails. fn render_patch_with_bat(shell: &dyn ShellTrait, patch: &str) -> Result { let cleaned_patch = Self::clean_patch_for_preview(patch); @@ -63,15 +56,8 @@ impl PatchRendererService { Ok(String::from_utf8(out.stdout)?) } - /// Renders a patch using the `delta` command line tool. - /// - /// # Errors - /// - /// If delta isn't installed or if the command fails, an error will be returned. - /// - /// # Tests - /// - /// [tests::test_delta_patch_renderer] + /// Renders a patch with `delta`. Returns an error when delta is missing + /// or the command fails. fn render_patch_with_delta(shell: &dyn ShellTrait, patch: &str) -> Result { let cleaned_patch = Self::clean_patch_for_preview(patch); @@ -99,15 +85,8 @@ impl PatchRendererService { Ok(String::from_utf8(out.stdout)?) } - /// Renders a patch using the `diff-so-fancy` command line tool. - /// - /// # Errors - /// - /// If diff-so-fancy isn't installed or if the command fails, an error will be returned. - /// - /// # Tests - /// - /// [tests::test_diff_so_fancy_renderer] + /// Renders a patch with `diff-so-fancy`. Returns an error when the tool + /// is missing or the command fails. fn render_patch_with_diff_so_fancy(shell: &dyn ShellTrait, patch: &str) -> Result { let cleaned_patch = Self::clean_patch_for_preview(patch); diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index abd27f1..ed1b44e 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -1,10 +1,8 @@ //! Terminal session actor: owns raw TUI I/O on a dedicated task. //! -//! `TerminalHandle` exposes draw, -//! poll event, size, and user-I/O setup as typed messages. The session -//! implementation (`TerminalSessionApi`, -//! e.g. crossterm) is moved into the actor at spawn time so no other component -//! holds the terminal directly. +//! `TerminalHandle` exposes draw, poll, size, and user-I/O setup. The +//! session (`TerminalSessionApi`, e.g. crossterm) moves into the actor at +//! spawn so nothing else holds the terminal. use tokio::{spawn, sync::mpsc, task}; use crate::infrastructure::actor_reply::ActorReplyService; diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index ba30ba6..03beb4f 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,11 +1,8 @@ //! Actor boundary for the Ratatui/Crossterm terminal session. //! -//! This module is the single owner of terminal session operations. The runtime -//! draws frames through [`TerminalHandle::draw`]. Raw terminal events -//! are delivered to the [`crate::input`] actor via -//! [`TerminalHandle::poll_event`]; the Input actor translates them into -//! semantic [`event::InputEvent`] values before forwarding them -//! to the App. +//! This module owns session operations. Frames are drawn through +//! `TerminalHandle::draw`. Raw events go to the input actor via +//! `poll_event`, which turns them into `InputEvent` values. pub mod actor; pub mod errors; diff --git a/src/ui/actor.rs b/src/ui/actor.rs index 69554f8..3520bf9 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -1,13 +1,8 @@ -//! UI presentation actor: builds `UiScene` values -//! from `AppViewModel` on a dedicated task. +//! UI presentation actor: builds `UiScene` values from `AppViewModel`. //! -//! `AppActor` calls -//! `UiHandle::build_scene` each frame; -//! this actor runs `UiCore::build_scene` -//! and returns an owned scene for the terminal draw path. Presentation logic stays -//! out of `AppState`: the app layer projects domain -//! state into `AppViewModel` before -//! crossing this boundary. +//! `AppActor` calls `UiHandle::build_scene` each frame; this actor runs +//! `UiCore::build_scene`. Presentation stays out of `AppState`: the app +//! projects domain state into `AppViewModel` before crossing this boundary. use std::{mem, ops::ControlFlow}; use tokio::{spawn, sync::mpsc}; diff --git a/src/ui/scene.rs b/src/ui/scene.rs index 054c906..3a84f59 100644 --- a/src/ui/scene.rs +++ b/src/ui/scene.rs @@ -1,9 +1,7 @@ -//! Scene types produced by [`core::UiCore`] and consumed by -//! [`super::painter`]. +//! Scene types produced by `UiCore` and consumed by the painter. //! -//! A `UiScene` is a fully projected, paint-ready snapshot of application -//! state. Nothing inside this module reads from `App`, `AppState`, or any -//! actor handle — it is pure presentation data. +//! A `UiScene` is a fully projected, paint-ready snapshot. Nothing here +//! reads `App`, `AppState`, or an actor handle. use ratatui::text::{Span, Text}; diff --git a/src/ui/screens/kw_ops.rs b/src/ui/screens/kw_ops.rs index 7be00a3..fa87def 100644 --- a/src/ui/screens/kw_ops.rs +++ b/src/ui/screens/kw_ops.rs @@ -55,10 +55,8 @@ impl KwOpsPainter { /// Scroll so the newest wrapped rows sit at the bottom of the pane. /// - /// Uses ratatui's wrap-aware [`Paragraph::line_count`] so the offset - /// matches what the painter actually renders (word wrap, tabs, wide - /// chars). Counted without a [`Block`] and with the inner width, so - /// border rows are not mixed into the text height. + /// Uses ratatui's wrap-aware `Paragraph::line_count` on the inner width + /// without a `Block`, so border rows are not mixed into the text height. pub(crate) fn compute_log_scroll_offset( text: &str, inner_width: u16, From cbefca3ce9495c04f13d963e125f9ebd90548f3b Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 14:03:10 -0300 Subject: [PATCH 51/61] style: separate logical sections inside long functions Blank lines mark the phases of multi-step functions in the kw actor, KwOps flow, and patchset apply: probe then switch then spawn, record then status, and outcome checks then the command trace. Signed-off-by: lorenzoberts --- src/app/actions/apply.rs | 2 ++ src/app/flows/kw_ops.rs | 2 ++ src/kw/actor/mod.rs | 8 ++++++++ 3 files changed, 12 insertions(+) diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index 3d7d405..16514f2 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -437,6 +437,7 @@ mod tests { assert!(applied .message .contains(&format!("Current branch: '{}'", applied.applied_branch))); + let calls = calls.lock().unwrap(); assert_eq!(6, calls.len()); assert_eq!( @@ -578,6 +579,7 @@ mod tests { assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed")); assert!(!result.contains("could not delete")); + let calls = calls.lock().unwrap(); assert_eq!(9, calls.len()); assert_eq!( diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 5cace74..504535f 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -377,6 +377,7 @@ impl App { self.state.popup = Some(AppPopup::info(kind.title(), body)); return Ok(()); } + // Record/tree refusals before the boot-once confirm so a deploy // without a build does not ask the user to proceed, then refuse. match kind { @@ -478,6 +479,7 @@ impl App { Some(trimmed.to_string()) } }; + match kw .get_readiness(&kernel_tree_id, &tree, for_branch.as_deref()) .await diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs index 2898f4e..c6d70ab 100644 --- a/src/kw/actor/mod.rs +++ b/src/kw/actor/mod.rs @@ -388,6 +388,7 @@ impl KwActor { "kw build job started" ), } + // Recorded only on accept. Refused starts never reach here, and — // with their switch rolled back — can neither clobber a previous // job's restore target nor strand the tree on a branch the user @@ -586,6 +587,7 @@ impl KwActor { ControlFlow::Break(()) => return, ControlFlow::Continue(job) => job, }; + // The record is written before the status flips: watchers // that react to the terminal status find the history // already durable. @@ -663,6 +665,7 @@ impl KwActor { } } }; + self.set_status(status); } } @@ -690,6 +693,7 @@ impl KwActor { { return ControlFlow::Continue(job); } + // Durable before the phase flips: a deploy spawn failure must not // lose the successful build, and KwOps watching Deploying must // already see the record. @@ -706,6 +710,7 @@ impl KwActor { return ControlFlow::Break(()); }; deploy.kernelrelease = kernelrelease; + match self.spawn_deploy_process(&job.tree_path, &deploy) { Ok((process, log_path)) => { let (cancel_tx, cancel_rx) = oneshot::channel(); @@ -786,6 +791,7 @@ impl KwActor { } else { (None, None) }; + let message_id = match self .history .apply_record_for_branch(&job.kernel_tree_id, &job.branch) @@ -817,6 +823,7 @@ impl KwActor { built_at: Utc::now().to_rfc3339(), success, }; + if let Err(error) = self.history.record_build(record) { tracing::warn!(%error, branch = job.branch, "failed to record kw build history"); } @@ -954,6 +961,7 @@ impl KwActor { }); let remote = remote::RemoteConfigService::resolve_deploy_remote(fs, env, tree_path) .map_err(KwStartError::RemoteUnresolved)?; + let mut kernelrelease = None; if kind == KwJobKind::Deploy { let (record, latest) = From 712624b44b25eb1c868b299c00b768ad1bd4c1ec Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 14:17:29 -0300 Subject: [PATCH 52/61] test: replace unwrap with expect in tests Panic sites in tests now name the call they were waiting on, so a failure points at that call instead of a bare unwrap. The messages stay short: "json parses", "build starts", "temp dir removes". Signed-off-by: lorenzoberts --- src/app/actions/apply.rs | 23 +- src/app/actor.rs | 36 +- src/app/flows/edit_config.rs | 27 +- src/app/flows/kw_ops.rs | 2 +- src/app/integration_tests/actor_lifecycle.rs | 16 +- src/app/integration_tests/flow_errors.rs | 8 +- src/app/integration_tests/flow_navigation.rs | 12 +- src/app/integration_tests/kw_ops.rs | 242 +++++++---- src/app/integration_tests/kw_status.rs | 96 +++-- src/app/integration_tests/mod.rs | 2 +- src/app/integration_tests/patchset_actions.rs | 85 ++-- src/app/loading.rs | 2 +- src/app/screens/edit_config.rs | 2 +- src/app/screens/latest.rs | 2 +- src/app/view_model.rs | 6 +- src/config/actor.rs | 33 +- src/config/tests.rs | 70 ++-- src/infrastructure/env/tests.rs | 2 +- src/infrastructure/file_system/tests.rs | 80 ++-- src/infrastructure/net/tests.rs | 2 +- src/infrastructure/process/fake.rs | 22 +- src/infrastructure/process/tests.rs | 92 +++-- src/infrastructure/shell/tests.rs | 17 +- src/input/actor.rs | 34 +- src/kw/actor/actor_test.rs | 388 ++++++++++++------ src/kw/actor/deploy_test.rs | 122 +++--- src/kw/history.rs | 215 ++++++---- src/kw/log_scan.rs | 2 +- src/kw/readiness.rs | 148 ++++--- src/kw/remote.rs | 26 +- src/lore/application/actor.rs | 9 +- src/lore/application/service.rs | 50 ++- src/lore/domain/mailing_list.rs | 5 +- src/lore/domain/patch.rs | 4 +- src/lore/domain/patchset.rs | 10 +- src/lore/infrastructure/parsers.rs | 6 +- src/lore/infrastructure/patchset_fetcher.rs | 5 +- src/lore/infrastructure/patchset_parser.rs | 20 +- src/lore/infrastructure/persistence.rs | 35 +- src/terminal/actor.rs | 17 +- src/ui/core.rs | 2 +- 41 files changed, 1226 insertions(+), 751 deletions(-) diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index 16514f2..c4051bf 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -394,10 +394,13 @@ mod tests { let calls_for_execute = Arc::clone(&calls); let outputs_for_execute = Arc::clone(&outputs); shell.expect_execute().returning(move |cmd| { - calls_for_execute.lock().unwrap().push(command_parts(cmd)); + calls_for_execute + .lock() + .expect("calls for execute locks") + .push(command_parts(cmd)); Ok(outputs_for_execute .lock() - .unwrap() + .expect("outputs for execute locks") .pop_front() .expect("test should provide one output per shell command")) }); @@ -426,8 +429,8 @@ mod tests { output("", "", true), ]); - let applied = - ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap(); + let applied = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()) + .expect("patchset applies"); assert!(applied.applied_branch.starts_with("patchset-")); assert!(applied @@ -438,7 +441,7 @@ mod tests { .message .contains(&format!("Current branch: '{}'", applied.applied_branch))); - let calls = calls.lock().unwrap(); + let calls = calls.lock().expect("calls locks"); assert_eq!(6, calls.len()); assert_eq!( &calls[0], @@ -505,10 +508,10 @@ mod tests { let applied = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config_stay_disabled()) - .unwrap(); + .expect("patchset applies"); assert!(applied.message.contains("Current branch: 'feature'")); - let calls = calls.lock().unwrap(); + let calls = calls.lock().expect("calls locks"); assert_eq!(7, calls.len()); assert_eq!( &calls[6], @@ -525,7 +528,7 @@ mod tests { ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); assert!(result.contains("there are staged and/or unstaged changes")); - assert_eq!(1, calls.lock().unwrap().len()); + assert_eq!(1, calls.lock().expect("calls locks").len()); } #[test] @@ -555,7 +558,7 @@ mod tests { ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); assert!(result.contains("invalid branch 'main'")); - assert_eq!(2, calls.lock().unwrap().len()); + assert_eq!(2, calls.lock().expect("calls locks").len()); } #[test] @@ -580,7 +583,7 @@ mod tests { assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed")); assert!(!result.contains("could not delete")); - let calls = calls.lock().unwrap(); + let calls = calls.lock().expect("calls locks"); assert_eq!(9, calls.len()); assert_eq!( &calls[6], diff --git a/src/app/actor.rs b/src/app/actor.rs index eef2870..38109ea 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -576,7 +576,7 @@ mod tests { "path": "/kernel", "branch": "main" })) - .unwrap(), + .expect("json parses"), KwReadiness { kw_binary: KwBinaryProbe { available: true, @@ -605,9 +605,9 @@ mod tests { let flow = AppActor::apply_confirm_action(&mut app, ConfirmAction::ProceedWithBootOnce) .await - .unwrap(); + .expect("confirm action applies"); assert_eq!(ControlFlow::Continue(()), flow); - let ops = app.state.kw.ops.as_ref().unwrap(); + let ops = app.state.kw.ops.as_ref().expect("ops is set"); assert!(ops.boot_once_acknowledged); assert_eq!(None, ops.pending_deploy); let Some(AppPopup::Info { title, body, .. }) = &app.state.popup else { @@ -630,10 +630,10 @@ mod tests { let flow = AppActor::apply_confirm_action(&mut app, ConfirmAction::BackOut) .await - .unwrap(); + .expect("confirm action applies"); assert_eq!(ControlFlow::Continue(()), flow); assert!(app.state.popup.is_none()); - let ops = app.state.kw.ops.as_ref().unwrap(); + let ops = app.state.kw.ops.as_ref().expect("ops is set"); assert!(!ops.boot_once_acknowledged); assert_eq!(None, ops.pending_deploy); } @@ -648,8 +648,23 @@ mod tests { AppActor::dismiss_open_popup(&mut app); assert!(app.state.popup.is_none()); - assert_eq!(None, app.state.kw.ops.as_ref().unwrap().pending_deploy); - assert!(!app.state.kw.ops.as_ref().unwrap().boot_once_acknowledged); + assert_eq!( + None, + app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .pending_deploy + ); + assert!( + !app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .boot_once_acknowledged + ); } #[test] @@ -664,7 +679,12 @@ mod tests { assert!(app.state.popup.is_none()); assert_eq!( Some(DeployStartKind::Deploy), - app.state.kw.ops.as_ref().unwrap().pending_deploy + app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .pending_deploy ); } } diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 116ae8e..223cfe6 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -141,7 +141,7 @@ mod tests { "patch-hub-edit-config-{prefix}-{}-{n}", process::id() )); - fs::create_dir_all(&p).unwrap(); + fs::create_dir_all(&p).expect("dir creates"); p } @@ -172,7 +172,8 @@ mod tests { fn app_with_kernel_trees(keys: &[&str], target: Option<&str>) -> (App, ConfigHandle, PathBuf) { let (env, home) = default_env(); - let (mut state, repo) = ConfigService::bootstrap_parts(&env, OsFileSystem).unwrap(); + let (mut state, repo) = + ConfigService::bootstrap_parts(&env, OsFileSystem).expect("config bootstraps"); for key in keys { state.kernel_trees.insert( (*key).to_string(), @@ -180,7 +181,7 @@ mod tests { "path": format!("/{key}"), "branch": "master" })) - .unwrap(), + .expect("json parses"), ); } state.target_kernel_tree = target.map(str::to_string); @@ -257,20 +258,20 @@ mod tests { for _ in 0..11 { app.handle_edit_config(InputEvent::NavigateDown) .await - .unwrap(); + .expect("edit config handles"); } app.handle_edit_config(InputEvent::EditConfigField) .await - .unwrap(); + .expect("edit config handles"); app.handle_edit_config(InputEvent::NavigateRight) .await - .unwrap(); + .expect("edit config handles"); app.handle_edit_config(InputEvent::StageConfigEdit) .await - .unwrap(); + .expect("edit config handles"); app.handle_edit_config(InputEvent::SaveConfig) .await - .unwrap(); + .expect("edit config handles"); assert_eq!( Some("linux"), @@ -282,8 +283,8 @@ mod tests { app.state.navigation.current_screen ); - let raw = fs::read_to_string(home.join(DEFAULT_CONFIG_PATH_SUFFIX)).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let raw = fs::read_to_string(home.join(DEFAULT_CONFIG_PATH_SUFFIX)).expect("file reads"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("config parses"); assert_eq!(parsed["target_kernel_tree"], "linux"); config.shutdown().await; } @@ -299,11 +300,11 @@ mod tests { app.handle_edit_config(InputEvent::SaveConfig) .await - .unwrap(); + .expect("edit config handles"); assert!(app.state.config.target_kernel_tree().is_none()); - let raw = fs::read_to_string(home.join(DEFAULT_CONFIG_PATH_SUFFIX)).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let raw = fs::read_to_string(home.join(DEFAULT_CONFIG_PATH_SUFFIX)).expect("file reads"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("config parses"); assert!(parsed["target_kernel_tree"].is_null()); config.shutdown().await; } diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 504535f..8ab50c0 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -596,7 +596,7 @@ mod tests { "path": "/kernel", "branch": "main" })) - .unwrap(), + .expect("json parses"), KwReadiness { kw_binary: KwBinaryProbe { available: true, diff --git a/src/app/integration_tests/actor_lifecycle.rs b/src/app/integration_tests/actor_lifecycle.rs index acbdc7d..c967fbe 100644 --- a/src/app/integration_tests/actor_lifecycle.rs +++ b/src/app/integration_tests/actor_lifecycle.rs @@ -45,7 +45,7 @@ async fn main_like_lifecycle_shuts_input_down_before_terminal() { input_handle .subscribe_app(app_input_tx.clone()) .await - .unwrap(); + .expect("app subscribes"); let app_handle = AppActor::spawn( app, @@ -55,12 +55,18 @@ async fn main_like_lifecycle_shuts_input_down_before_terminal() { app_input_rx, ); - app_input_tx.send(InputEvent::Quit).await.unwrap(); - app_handle.run_until_done().await.unwrap(); + app_input_tx + .send(InputEvent::Quit) + .await + .expect("quit sends"); + app_handle.run_until_done().await.expect("actor finishes"); - input_shutdown_handle.shutdown().await.unwrap(); + input_shutdown_handle + .shutdown() + .await + .expect("actor shuts down"); input_shutdown_complete.store(true, Ordering::SeqCst); ui_handle.shutdown().await; - terminal_handle.shutdown().await.unwrap(); + terminal_handle.shutdown().await.expect("actor shuts down"); } diff --git a/src/app/integration_tests/flow_errors.rs b/src/app/integration_tests/flow_errors.rs index 73ddaee..7c25134 100644 --- a/src/app/integration_tests/flow_errors.rs +++ b/src/app/integration_tests/flow_errors.rs @@ -24,14 +24,14 @@ async fn latest_render_failure_shows_popup_and_stays_on_latest() { .app .handle_mailing_list_selection(InputEvent::OpenLatestPatchsets, &mut loading) .await - .unwrap(); + .expect("mailing list selection handles"); assert_eq!(ControlFlow::Continue(()), result); harness .app .handle_latest_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) .await - .unwrap(); + .expect("latest patchsets handles"); assert_eq!( CurrentScreen::LatestPatchsets, @@ -60,14 +60,14 @@ async fn bookmarked_lore_failure_shows_popup_and_stays_on_bookmarks() { .app .handle_mailing_list_selection(InputEvent::OpenBookmarkedPatchsets, &mut loading) .await - .unwrap(); + .expect("mailing list selection handles"); assert_eq!(ControlFlow::Continue(()), result); harness .app .handle_bookmarked_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) .await - .unwrap(); + .expect("bookmarked patchsets handles"); assert_eq!( CurrentScreen::BookmarkedPatchsets, diff --git a/src/app/integration_tests/flow_navigation.rs b/src/app/integration_tests/flow_navigation.rs index 67ff55b..e9b8aa2 100644 --- a/src/app/integration_tests/flow_navigation.rs +++ b/src/app/integration_tests/flow_navigation.rs @@ -21,7 +21,7 @@ async fn latest_list_opens_details_and_back_returns_to_latest() { .app .handle_mailing_list_selection(InputEvent::OpenLatestPatchsets, &mut loading) .await - .unwrap(); + .expect("mailing list selection handles"); assert_eq!(ControlFlow::Continue(()), result); assert_eq!( CurrentScreen::LatestPatchsets, @@ -32,7 +32,7 @@ async fn latest_list_opens_details_and_back_returns_to_latest() { .app .handle_latest_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) .await - .unwrap(); + .expect("latest patchsets handles"); assert_eq!( CurrentScreen::PatchsetDetails, harness.app.state.navigation.current_screen @@ -43,7 +43,7 @@ async fn latest_list_opens_details_and_back_returns_to_latest() { .app .handle_patchset_details(InputEvent::Back, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); assert_eq!( CurrentScreen::LatestPatchsets, @@ -64,7 +64,7 @@ async fn bookmarked_list_opens_details_and_back_returns_to_bookmarks() { .app .handle_mailing_list_selection(InputEvent::OpenBookmarkedPatchsets, &mut loading) .await - .unwrap(); + .expect("mailing list selection handles"); assert_eq!(ControlFlow::Continue(()), result); assert_eq!( CurrentScreen::BookmarkedPatchsets, @@ -75,7 +75,7 @@ async fn bookmarked_list_opens_details_and_back_returns_to_bookmarks() { .app .handle_bookmarked_patchsets(InputEvent::OpenPatchsetDetails, &mut loading) .await - .unwrap(); + .expect("bookmarked patchsets handles"); assert_eq!( CurrentScreen::PatchsetDetails, harness.app.state.navigation.current_screen @@ -86,7 +86,7 @@ async fn bookmarked_list_opens_details_and_back_returns_to_bookmarks() { .app .handle_patchset_details(InputEvent::Back, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); assert_eq!( CurrentScreen::BookmarkedPatchsets, diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index ac07c68..e7d9b47 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -22,7 +22,7 @@ async fn open_kw_ops_without_actor_stays_on_details() { .app .handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); assert_eq!( CurrentScreen::PatchsetDetails, @@ -118,7 +118,7 @@ mod unix { app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); let ops = app.state.kw.ops.as_ref().expect("KwOps state"); @@ -127,7 +127,7 @@ mod unix { assert!(!ops.head_unreadable); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -136,7 +136,7 @@ mod unix { let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("")); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); assert!(app .state .kw @@ -144,7 +144,9 @@ mod unix { .as_ref() .is_some_and(|ops| ops.branch.is_empty())); - app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); assert_info_popup( app.state.popup.as_ref(), "Cannot start build", @@ -152,7 +154,7 @@ mod unix { ); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -167,23 +169,27 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); - app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); assert!(app.state.popup.is_none()); let spawned = process.spawned(); assert_eq!(1, spawned.len()); assert_eq!(vec!["build"], spawned[0].args); - let kw = app.services.kw.as_ref().unwrap().clone(); - let status = kw.get_status().await.unwrap(); + let kw = app.services.kw.as_ref().expect("kw is set").clone(); + let status = kw.get_status().await.expect("status loads"); app.state.kw.status = Some(status); assert!(matches!( app.state.kw.status.as_ref().map(|s| &s.job), Some(KwJobStatus::Running { .. }) )); - app.handle_kw_ops(InputEvent::CancelKwJob).await.unwrap(); + app.handle_kw_ops(InputEvent::CancelKwJob) + .await + .expect("kw ops handles"); assert!(app .state .kw @@ -193,7 +199,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -208,9 +214,11 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); - app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); assert!(app.state.popup.is_none()); assert!(matches!( app.state.kw.status.as_ref().map(|s| &s.job), @@ -223,13 +231,15 @@ mod unix { .as_ref() .is_some_and(|ops| ops.start_requested)); - app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); assert!(app.state.popup.is_none()); assert_eq!(1, process.spawned().len()); process.last_child().finish(0); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -244,8 +254,10 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); - app.handle_kw_ops(InputEvent::StartKwBuild).await.unwrap(); + .expect("patchset details handles"); + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); app.state.kw.status = None; app.fallback_kw_status().await; @@ -256,7 +268,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -274,7 +286,7 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); app.state.kw.status = Some(KwStatusSnapshot { job: KwJobStatus::Running { kind: KwJobKind::Build, @@ -295,7 +307,7 @@ mod unix { .is_some_and(|ops| ops.log_tail.contains("from mock"))); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -304,19 +316,24 @@ mod unix { let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); - app.state.kw.ops.as_mut().unwrap().extra_args = "--verbose".to_string(); + .expect("patchset details handles"); + app.state.kw.ops.as_mut().expect("ops is set").extra_args = "--verbose".to_string(); - app.handle_kw_ops(InputEvent::Back).await.unwrap(); + app.handle_kw_ops(InputEvent::Back) + .await + .expect("kw ops handles"); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); - assert_eq!("--verbose", app.state.kw.ops.as_ref().unwrap().extra_args); + assert_eq!( + "--verbose", + app.state.kw.ops.as_ref().expect("ops is set").extra_args + ); assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -325,8 +342,10 @@ mod unix { let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); - app.handle_kw_ops(InputEvent::Back).await.unwrap(); + .expect("patchset details handles"); + app.handle_kw_ops(InputEvent::Back) + .await + .expect("kw ops handles"); assert_eq!( CurrentScreen::PatchsetDetails, app.state.navigation.current_screen @@ -334,7 +353,7 @@ mod unix { assert!(app.state.lore.details.is_some()); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -350,17 +369,21 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); let ops = app.state.kw.ops.as_ref().expect("KwOps state"); assert_eq!( "root@box:22", - ops.readiness.deploy_remote.as_ref().unwrap().endpoint() + ops.readiness + .deploy_remote + .as_ref() + .expect("deploy remote is set") + .endpoint() ); assert_eq!(BootOnceState::Off, ops.readiness.boot_once); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -377,9 +400,11 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); - app.handle_kw_ops(InputEvent::StartKwDeploy).await.unwrap(); + app.handle_kw_ops(InputEvent::StartKwDeploy) + .await + .expect("kw ops handles"); let Some(AppPopup::Confirm { title, .. }) = app.state.popup.as_ref() else { panic!("expected boot-once confirm popup"); @@ -387,12 +412,17 @@ mod unix { assert_eq!("Boot into new kernel once?", title); assert_eq!( Some(DeployStartKind::Deploy), - app.state.kw.ops.as_ref().unwrap().pending_deploy + app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .pending_deploy ); assert!(process.spawned().is_empty()); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -409,9 +439,11 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); - app.handle_kw_ops(InputEvent::StartKwDeploy).await.unwrap(); + app.handle_kw_ops(InputEvent::StartKwDeploy) + .await + .expect("kw ops handles"); assert_info_popup( app.state.popup.as_ref(), "Cannot start deploy", @@ -420,7 +452,7 @@ mod unix { assert!(process.spawned().is_empty()); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -437,9 +469,11 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); - app.handle_kw_ops(InputEvent::StartKwDeploy).await.unwrap(); + app.handle_kw_ops(InputEvent::StartKwDeploy) + .await + .expect("kw ops handles"); assert!(app.state.popup.is_none()); let spawned = process.spawned(); assert_eq!(1, spawned.len()); @@ -465,7 +499,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -482,11 +516,11 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); app.handle_kw_ops(InputEvent::StartKwBuildThenDeploy) .await - .unwrap(); + .expect("kw ops handles"); assert!(app.state.popup.is_none()); assert_eq!(vec!["build"], process.spawned()[0].args); @@ -508,7 +542,7 @@ mod unix { process.last_child().finish(0); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -539,23 +573,33 @@ mod unix { ); app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) .await - .unwrap(); + .expect("patchset details handles"); assert!(matches!( - app.state.kw.ops.as_ref().unwrap().readiness.deploy_alone, + app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .readiness + .deploy_alone, Err(DeployAloneRefusal::NoBuildRecord) )); - app.handle_kw_ops(InputEvent::EditKwOpsField).await.unwrap(); - app.state.kw.ops.as_mut().unwrap().edit_buffer = "built".to_string(); - app.handle_kw_ops(InputEvent::StageKwOpsEdit).await.unwrap(); + app.handle_kw_ops(InputEvent::EditKwOpsField) + .await + .expect("kw ops handles"); + app.state.kw.ops.as_mut().expect("ops is set").edit_buffer = "built".to_string(); + app.handle_kw_ops(InputEvent::StageKwOpsEdit) + .await + .expect("kw ops handles"); - let ops = app.state.kw.ops.as_ref().unwrap(); + let ops = app.state.kw.ops.as_ref().expect("ops is set"); assert_eq!("built", ops.branch); assert_eq!(Ok(()), ops.readiness.deploy_alone); assert_eq!(Some("feature".to_string()), ops.readiness.current_branch); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -570,25 +614,34 @@ mod unix { deploy_kw_fs(false), feature_build_history(Some(matching_feature_build_record())), ); - let kw = app.services.kw.as_ref().unwrap().clone(); + let kw = app.services.kw.as_ref().expect("kw is set").clone(); let (scenes, event_tx, handle) = spawn_app_actor(app); - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); wait_for_kw_ops(&scenes, |_| true).await; - event_tx.send(InputEvent::StartKwDeploy).await.unwrap(); + event_tx + .send(InputEvent::StartKwDeploy) + .await + .expect("start kw deploy sends"); wait_for_latest_popup(&scenes, |popup| { popup.is_some_and(|popup| popup.title == "Boot into new kernel once?") }) .await; - event_tx.send(InputEvent::ConfirmPopup).await.unwrap(); + event_tx + .send(InputEvent::ConfirmPopup) + .await + .expect("confirm popup sends"); wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; assert!(process.spawned().is_empty()); drop(event_tx); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -603,16 +656,28 @@ mod unix { deploy_kw_fs(false), feature_build_history(Some(matching_feature_build_record())), ); - let kw = app.services.kw.as_ref().unwrap().clone(); + let kw = app.services.kw.as_ref().expect("kw is set").clone(); let (scenes, event_tx, handle) = spawn_app_actor(app); - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); wait_for_kw_ops(&scenes, |_| true).await; - event_tx.send(InputEvent::StartKwDeploy).await.unwrap(); + event_tx + .send(InputEvent::StartKwDeploy) + .await + .expect("start kw deploy sends"); wait_for_latest_popup(&scenes, |popup| popup.is_some()).await; - event_tx.send(InputEvent::NavigateRight).await.unwrap(); - event_tx.send(InputEvent::ConfirmPopup).await.unwrap(); + event_tx + .send(InputEvent::NavigateRight) + .await + .expect("navigate right sends"); + event_tx + .send(InputEvent::ConfirmPopup) + .await + .expect("confirm popup sends"); wait_for_nav(&scenes, |text| text.contains("kw: deploying feature")).await; wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; @@ -622,9 +687,9 @@ mod unix { process.last_child().finish(0); drop(event_tx); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -637,13 +702,19 @@ mod unix { process.clone(), Arc::new(OsFileSystem), ); - let kw = app.services.kw.as_ref().unwrap().clone(); + let kw = app.services.kw.as_ref().expect("kw is set").clone(); let (scenes, event_tx, handle) = spawn_app_actor(app); - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); wait_for_kw_ops(&scenes, |_| true).await; - event_tx.send(InputEvent::StartKwBuild).await.unwrap(); + event_tx + .send(InputEvent::StartKwBuild) + .await + .expect("start kw build sends"); wait_for_nav(&scenes, |text| text.contains("kw: building feature")).await; wait_for_kw_ops(&scenes, |ops| { ops.log_tail.contains("Waiting for kw output") @@ -653,7 +724,7 @@ mod unix { process.last_child().write_log(b"cc1: compiling foo.c\n"); wait_for_kw_ops(&scenes, |ops| ops.log_tail.contains("cc1: compiling foo.c")).await; - event_tx.send(InputEvent::Back).await.unwrap(); + event_tx.send(InputEvent::Back).await.expect("back sends"); wait_for_details(&scenes).await; wait_for_nav(&scenes, |text| text.contains("kw: building feature")).await; @@ -667,7 +738,10 @@ mod unix { "log ticks must not redraw while away from KwOps" ); - event_tx.send(InputEvent::ToggleBookmark).await.unwrap(); + event_tx + .send(InputEvent::ToggleBookmark) + .await + .expect("toggle bookmark sends"); time::timeout(Duration::from_secs(5), async { loop { if scene_count(&scenes) > draws_on_details { @@ -679,7 +753,10 @@ mod unix { .await .expect("input must still redraw off KwOps"); - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); wait_for_kw_ops(&scenes, |ops| { ops.log_tail.contains("cc1: compiling foo.c") && ops.log_tail.contains("ld: linking vmlinux") @@ -703,9 +780,9 @@ mod unix { ); drop(event_tx); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } fn app_with_details_and_kw(log_dir: &Path, kw_shell: MockShellTrait) -> App { @@ -939,7 +1016,7 @@ mod unix { process::id() )); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); dir } @@ -959,7 +1036,10 @@ mod unix { .times(1..) .returning(move |frame| { if let TerminalFrame::Main(scene) = frame { - scenes_for_draw.lock().unwrap().push(*scene); + scenes_for_draw + .lock() + .expect("scenes for draw locks") + .push(*scene); } Ok(()) }); @@ -978,7 +1058,7 @@ mod unix { loop { if scenes .lock() - .unwrap() + .expect("scenes locks") .iter() .any(|scene| predicate(&nav_text(scene))) { @@ -997,7 +1077,7 @@ mod unix { ) { time::timeout(Duration::from_secs(5), async { loop { - let matches = scenes.lock().unwrap().last().is_some_and( + let matches = scenes.lock().expect("scenes locks").last().is_some_and( |scene| matches!(&scene.body, UiBody::KwOps(ops) if predicate(ops)), ); if matches { @@ -1018,7 +1098,7 @@ mod unix { loop { let matches = scenes .lock() - .unwrap() + .expect("scenes locks") .last() .is_some_and(|scene| predicate(scene.popup.as_ref())); if matches { @@ -1036,7 +1116,7 @@ mod unix { loop { if scenes .lock() - .unwrap() + .expect("scenes locks") .last() .is_some_and(|scene| matches!(scene.body, UiBody::PatchsetDetails(_))) { @@ -1050,7 +1130,7 @@ mod unix { } fn scene_count(scenes: &Arc>>) -> usize { - scenes.lock().unwrap().len() + scenes.lock().expect("scenes locks").len() } fn nav_text(scene: &UiScene) -> String { diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index 0ac1e0f..aff228f 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -48,16 +48,16 @@ static LOG_DIR_SEQ: AtomicU64 = AtomicU64::new(0); async fn first_frame_shows_a_job_that_started_before_attach() { let log_dir = kw_log_dir("attach-running"); let (app, kw, process) = app_with_kw(&log_dir); - kw.start_build(start_request()).await.unwrap(); + kw.start_build(start_request()).await.expect("build starts"); let (scenes, event_tx, handle) = spawn_app_actor(app); wait_for_nav(&scenes, |text| text.contains("kw: building")).await; process.last_child().finish(0); drop(event_tx); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -69,7 +69,7 @@ async fn status_change_redraws_without_input() { wait_for_nav(&scenes, |text| !text.contains("kw:")).await; let draws_before_start = scene_count(&scenes); - kw.start_build(start_request()).await.unwrap(); + kw.start_build(start_request()).await.expect("build starts"); wait_for_nav(&scenes, |text| { text.contains(&format!("kw: building {BUILD_BRANCH}")) }) @@ -83,9 +83,9 @@ async fn status_change_redraws_without_input() { wait_for_nav(&scenes, |text| !text.contains("kw:")).await; drop(event_tx); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -107,7 +107,10 @@ async fn closed_watch_does_not_busy_loop_and_input_still_works() { "a closed kw watch must not spin the render loop" ); - event_tx.send(InputEvent::NavigateDown).await.unwrap(); + event_tx + .send(InputEvent::NavigateDown) + .await + .expect("navigate down sends"); time::timeout(Duration::from_secs(5), async { loop { if scene_count(&scenes) > draws_after_close { @@ -120,8 +123,8 @@ async fn closed_watch_does_not_busy_loop_and_input_still_works() { .expect("input must still redraw after the kw watch closes"); drop(event_tx); - handle.run_until_done().await.unwrap(); - fs::remove_dir_all(&log_dir).unwrap(); + handle.run_until_done().await.expect("actor finishes"); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -131,31 +134,36 @@ async fn quit_with_no_job_exits_without_confirm() { let (scenes, event_tx, handle) = spawn_app_actor(app); wait_for_nav(&scenes, |_| true).await; - event_tx.send(InputEvent::Quit).await.unwrap(); + event_tx.send(InputEvent::Quit).await.expect("quit sends"); time::timeout(Duration::from_secs(5), handle.run_until_done()) .await .expect("quit with no running job must exit without a confirm popup") - .unwrap(); + .expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] async fn quit_while_job_running_opens_confirm_and_wait_keeps_app_alive() { let log_dir = kw_log_dir("quit-wait"); let (app, kw, process) = app_with_kw(&log_dir); - kw.start_build(start_request()).await.unwrap(); + kw.start_build(start_request()).await.expect("build starts"); let (scenes, event_tx, handle) = spawn_app_actor(app); wait_for_nav(&scenes, |text| text.contains("kw: building")).await; - event_tx.send(InputEvent::Quit).await.unwrap(); + event_tx.send(InputEvent::Quit).await.expect("quit sends"); wait_for_latest_popup(&scenes, |popup| { popup.is_some_and(|popup| popup.title == "Cancel job and quit?") }) .await; - let latest = scenes.lock().unwrap().last().cloned().unwrap(); + let latest = scenes + .lock() + .expect("scenes locks") + .last() + .cloned() + .expect("last scene is set"); let popup = latest.popup.expect("confirm popup"); match popup.body { PopupBody::Confirm { @@ -170,55 +178,70 @@ async fn quit_while_job_running_opens_confirm_and_wait_keeps_app_alive() { other => panic!("expected Confirm scene, got {other:?}"), } - event_tx.send(InputEvent::ConfirmPopup).await.unwrap(); + event_tx + .send(InputEvent::ConfirmPopup) + .await + .expect("confirm popup sends"); wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; wait_for_nav(&scenes, |text| text.contains("kw: building")).await; - event_tx.send(InputEvent::NavigateDown).await.unwrap(); + event_tx + .send(InputEvent::NavigateDown) + .await + .expect("navigate down sends"); process.last_child().finish(0); drop(event_tx); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] async fn quit_confirm_esc_is_wait() { let log_dir = kw_log_dir("quit-esc-wait"); let (app, kw, process) = app_with_kw(&log_dir); - kw.start_build(start_request()).await.unwrap(); + kw.start_build(start_request()).await.expect("build starts"); let (scenes, event_tx, handle) = spawn_app_actor(app); wait_for_nav(&scenes, |text| text.contains("kw: building")).await; - event_tx.send(InputEvent::Quit).await.unwrap(); + event_tx.send(InputEvent::Quit).await.expect("quit sends"); wait_for_latest_popup(&scenes, |popup| popup.is_some()).await; - event_tx.send(InputEvent::ClosePopup).await.unwrap(); + event_tx + .send(InputEvent::ClosePopup) + .await + .expect("close popup sends"); wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; wait_for_nav(&scenes, |text| text.contains("kw: building")).await; process.last_child().finish(0); drop(event_tx); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] async fn cancel_and_quit_requests_cancellation() { let log_dir = kw_log_dir("quit-cancel"); let (app, kw, process) = app_with_kw(&log_dir); - kw.start_build(start_request()).await.unwrap(); + kw.start_build(start_request()).await.expect("build starts"); let (scenes, event_tx, handle) = spawn_app_actor(app); wait_for_nav(&scenes, |text| text.contains("kw: building")).await; - event_tx.send(InputEvent::Quit).await.unwrap(); + event_tx.send(InputEvent::Quit).await.expect("quit sends"); wait_for_latest_popup(&scenes, |popup| popup.is_some()).await; - event_tx.send(InputEvent::NavigateLeft).await.unwrap(); - event_tx.send(InputEvent::ConfirmPopup).await.unwrap(); + event_tx + .send(InputEvent::NavigateLeft) + .await + .expect("navigate left sends"); + event_tx + .send(InputEvent::ConfirmPopup) + .await + .expect("confirm popup sends"); - handle.run_until_done().await.unwrap(); + handle.run_until_done().await.expect("actor finishes"); time::timeout(Duration::from_secs(5), async { loop { if process.last_child().was_killed() { @@ -231,7 +254,7 @@ async fn cancel_and_quit_requests_cancellation() { .expect("cancel-and-quit must request kw cancellation"); drop(event_tx); kw.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } fn spawn_app_actor( @@ -250,7 +273,10 @@ fn spawn_app_actor( .times(1..) .returning(move |frame| { if let TerminalFrame::Main(scene) = frame { - scenes_for_draw.lock().unwrap().push(*scene); + scenes_for_draw + .lock() + .expect("scenes for draw locks") + .push(*scene); } Ok(()) }); @@ -303,7 +329,7 @@ async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str loop { if scenes .lock() - .unwrap() + .expect("scenes locks") .iter() .any(|scene| predicate(&nav_text(scene))) { @@ -324,7 +350,7 @@ async fn wait_for_latest_popup( loop { let matches = scenes .lock() - .unwrap() + .expect("scenes locks") .last() .is_some_and(|scene| predicate(scene.popup.as_ref())); if matches { @@ -338,7 +364,7 @@ async fn wait_for_latest_popup( } fn scene_count(scenes: &Arc>>) -> usize { - scenes.lock().unwrap().len() + scenes.lock().expect("scenes locks").len() } fn nav_text(scene: &UiScene) -> String { @@ -358,7 +384,7 @@ fn kw_log_dir(test_name: &str) -> PathBuf { process::id() )); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); dir } diff --git a/src/app/integration_tests/mod.rs b/src/app/integration_tests/mod.rs index 76e7ad8..99d4d67 100644 --- a/src/app/integration_tests/mod.rs +++ b/src/app/integration_tests/mod.rs @@ -42,7 +42,7 @@ fn fake_loading_indicator_records_start_and_stop() { let mut loading = FakeLoadingIndicator::default(); loading.start("Loading patchset".to_string()); - loading.stop().unwrap(); + loading.stop().expect("actor stops"); assert_eq!(vec!["Loading patchset"], loading.starts); assert_eq!(1, loading.stop_count); diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index 8d30a14..45fae5b 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -62,7 +62,9 @@ async fn apply_success_sets_success_popup_and_resets_apply_action() { ]); let mut app = app_with_apply_details(clean_fs(), shell); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_apply_action(&app, false); assert_info_popup_contains( @@ -98,7 +100,9 @@ async fn apply_success_switches_back_when_stay_disabled() { history_store_allowing_writes(), ); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_apply_action(&app, false); assert_info_popup_contains( @@ -107,7 +111,7 @@ async fn apply_success_switches_back_when_stay_disabled() { &["Current branch: 'feature'"], ); { - let calls = calls.lock().unwrap(); + let calls = calls.lock().expect("calls locks"); assert_eq!( command(&["git", "-C", KERNEL_TREE_PATH, "switch", "feature"]), calls[6] @@ -131,7 +135,9 @@ async fn apply_failure_sets_failure_popup_and_resets_apply_action() { ]); let mut app = app_with_apply_details(clean_fs(), shell); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_apply_action(&app, false); assert_info_popup_contains( @@ -140,7 +146,7 @@ async fn apply_failure_sets_failure_popup_and_resets_apply_action() { &["`git am` failed (back on branch 'feature')", "apply failed"], ); { - let calls = calls.lock().unwrap(); + let calls = calls.lock().expect("calls locks"); assert_eq!( command(&["git", "-C", KERNEL_TREE_PATH, "am", "--abort"]), calls[6] @@ -185,7 +191,9 @@ async fn apply_success_records_apply_history() { kw_history, ); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_info_popup_contains( app.state.popup.as_ref(), @@ -219,7 +227,9 @@ async fn apply_success_with_history_write_failure_keeps_success_popup() { kw_history, ); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_apply_action(&app, false); assert_info_popup_contains( @@ -259,7 +269,9 @@ async fn apply_failure_does_not_record_history() { kw_history, ); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_info_popup_contains(app.state.popup.as_ref(), "Patchset Apply Fail", &[]); shutdown_kw(&app).await; @@ -285,9 +297,13 @@ async fn apply_is_blocked_while_a_kw_job_runs() { ); let kw = app.services.kw.as_ref().expect("kw handle is available"); - kw.start_build(kw_start_request()).await.unwrap(); + kw.start_build(kw_start_request()) + .await + .expect("build starts"); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_apply_action(&app, false); assert_info_popup_contains( @@ -295,10 +311,10 @@ async fn apply_is_blocked_while_a_kw_job_runs() { "Patchset Apply Blocked", &["kw job is running", "Wait for the job to finish"], ); - assert!(calls.lock().unwrap().is_empty()); + assert!(calls.lock().expect("calls locks").is_empty()); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -339,22 +355,26 @@ async fn apply_is_allowed_again_after_the_job_finishes() { .as_ref() .expect("kw handle is available") .clone(); - kw.start_build(kw_start_request()).await.unwrap(); + kw.start_build(kw_start_request()) + .await + .expect("build starts"); // While the job runs, the apply is blocked. - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_info_popup_contains(app.state.popup.as_ref(), "Patchset Apply Blocked", &[]); - assert!(calls.lock().unwrap().is_empty()); + assert!(calls.lock().expect("calls locks").is_empty()); // Once the job finishes, the same apply goes through. process.last_child().finish(0); - let mut watch = kw.watch_status().await.unwrap(); + let mut watch = kw.watch_status().await.expect("status watch opens"); time::timeout(Duration::from_secs(10), async { loop { if matches!(watch.borrow().job, KwJobStatus::Succeeded { .. }) { break; } - watch.changed().await.unwrap(); + watch.changed().await.expect("watch notifies"); } }) .await @@ -367,7 +387,9 @@ async fn apply_is_allowed_again_after_the_job_finishes() { .as_mut() .expect("details should remain loaded"); details.toggle_apply_action(); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_apply_action(&app, false); assert_info_popup_contains( @@ -375,10 +397,10 @@ async fn apply_is_allowed_again_after_the_job_finishes() { "Patchset Apply Success", &["applied successfully"], ); - assert_eq!(6, calls.lock().unwrap().len()); + assert_eq!(6, calls.lock().expect("calls locks").len()); shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -398,7 +420,9 @@ async fn reviewed_reply_success_records_persists_and_resets_reply_action() { let mut app = app_with_reviewed_reply_details(shell, lore_api); let message_id = selected_message_id(&app); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_reply_action_reset(&app); assert_eq!( @@ -407,7 +431,7 @@ async fn reviewed_reply_success_records_persists_and_resets_reply_action() { ); let saved = saved_reviewed .lock() - .unwrap() + .expect("saved reviewed locks") .clone() .expect("reviewed state should be persisted"); assert_eq!(HashSet::from([0]), saved[&message_id]); @@ -431,13 +455,15 @@ async fn reviewed_reply_failure_does_not_record_failed_index() { let mut app = app_with_reviewed_reply_details(shell, lore_api); let message_id = selected_message_id(&app); - app.consolidate_patchset_actions().await.unwrap(); + app.consolidate_patchset_actions() + .await + .expect("patchset actions consolidate"); assert_reply_action_reset(&app); assert!(app.state.user_state.reviewed_patchsets[&message_id].is_empty()); let saved = saved_reviewed .lock() - .unwrap() + .expect("saved reviewed locks") .clone() .expect("reviewed state should be persisted"); assert!(saved[&message_id].is_empty()); @@ -594,7 +620,7 @@ fn reviewed_reply_lore_handle(saved_reviewed: SharedReviewedState) -> LoreApiHan .ok(); } LoreApiMessage::SaveReviewed { reviewed, reply } => { - *saved_reviewed.lock().unwrap() = Some(reviewed); + *saved_reviewed.lock().expect("saved reviewed locks") = Some(reviewed); reply.send(Ok(())).ok(); } LoreApiMessage::Shutdown => break, @@ -663,10 +689,13 @@ fn shell_with_outputs(outputs: Vec) -> (MockShellTrait, Arc PathBuf { process::id() )); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); dir } diff --git a/src/app/loading.rs b/src/app/loading.rs index a4f4836..471ee81 100644 --- a/src/app/loading.rs +++ b/src/app/loading.rs @@ -105,6 +105,6 @@ mod tests { loading.start("Fetching mailing lists".to_string()); thread::sleep(LOADING_FRAME_INTERVAL); - loading.stop().unwrap(); + loading.stop().expect("actor stops"); } } diff --git a/src/app/screens/edit_config.rs b/src/app/screens/edit_config.rs index b186bf5..1367694 100644 --- a/src/app/screens/edit_config.rs +++ b/src/app/screens/edit_config.rs @@ -327,7 +327,7 @@ mod tests { "path": format!("/{key}"), "branch": "master" })) - .unwrap(), + .expect("json parses"), ); } state.target_kernel_tree = target.map(str::to_string); diff --git a/src/app/screens/latest.rs b/src/app/screens/latest.rs index f88f618..4f20f13 100644 --- a/src/app/screens/latest.rs +++ b/src/app/screens/latest.rs @@ -135,7 +135,7 @@ mod tests { "link": { "@href": msg_id }, "updated": "2023-01-01" })) - .unwrap() + .expect("json parses") } fn make_handle(feed_gateway: MockFeedGateway) -> LoreApiHandle { diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 85e663f..45fbcdb 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -761,7 +761,7 @@ mod tests { "path": "/linux", "branch": "master" })) - .unwrap(), + .expect("json parses"), ); state.config = ConfigSnapshot::from(&config); let mut edit = EditConfigState::new(&state.config); @@ -808,7 +808,7 @@ mod tests { "path": "/kernel", "branch": "main" })) - .unwrap(), + .expect("json parses"), KwReadiness { kw_binary: KwBinaryProbe { available: true, @@ -853,7 +853,7 @@ mod tests { "path": "/kernel", "branch": "main" })) - .unwrap(), + .expect("json parses"), KwReadiness { kw_binary: KwBinaryProbe { available: true, diff --git a/src/config/actor.rs b/src/config/actor.rs index a899f6f..3b30a1c 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -133,7 +133,7 @@ mod tests { fn unique_test_dir(prefix: &str) -> PathBuf { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{n}", process::id())); - fs::create_dir_all(&p).unwrap(); + fs::create_dir_all(&p).expect("dir creates"); p } @@ -164,7 +164,8 @@ mod tests { fn spawn_test_actor() -> (ConfigHandle, PathBuf) { let (env, home) = default_env(); - let (state, repo) = ConfigService::bootstrap_parts(&env, OsFileSystem).unwrap(); + let (state, repo) = + ConfigService::bootstrap_parts(&env, OsFileSystem).expect("config bootstraps"); (ConfigActor::spawn(state, repo), home) } @@ -172,7 +173,7 @@ mod tests { async fn get_snapshot_returns_actor_state() { let (handle, _home) = spawn_test_actor(); - let snapshot = handle.get_snapshot().await.unwrap(); + let snapshot = handle.get_snapshot().await.expect("snapshot loads"); assert_eq!(30, snapshot.page_size()); handle.shutdown().await; @@ -188,10 +189,17 @@ mod tests { ..Default::default() }) .await - .unwrap(); + .expect("update applies"); assert_eq!(77, snapshot.page_size()); - assert_eq!(77, handle.get_snapshot().await.unwrap().page_size()); + assert_eq!( + 77, + handle + .get_snapshot() + .await + .expect("snapshot loads") + .page_size() + ); handle.shutdown().await; } @@ -206,10 +214,10 @@ mod tests { ..Default::default() }) .await - .unwrap(); + .expect("update applies"); - let raw = fs::read_to_string(&cfg_path).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let raw = fs::read_to_string(&cfg_path).expect("file reads"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("config parses"); assert_eq!(parsed["page_size"], 88); handle.shutdown().await; } @@ -227,7 +235,14 @@ mod tests { .unwrap_err(); assert!(matches!(err, ConfigError::InvalidPageSize(ref s) if s == "not-a-number")); - assert_eq!(30, handle.get_snapshot().await.unwrap().page_size()); + assert_eq!( + 30, + handle + .get_snapshot() + .await + .expect("snapshot loads") + .page_size() + ); handle.shutdown().await; } diff --git a/src/config/tests.rs b/src/config/tests.rs index 4e153ea..9fa7b89 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -33,7 +33,7 @@ fn sample_kernel_tree(path: &str, branch: &str) -> KernelTree { "path": path, "branch": branch })) - .unwrap() + .expect("json parses") } fn state_with_trees(env: &dyn EnvTrait) -> ConfigState { @@ -52,12 +52,16 @@ fn state_with_trees(env: &dyn EnvTrait) -> ConfigState { fn unique_test_dir(prefix: &str) -> PathBuf { let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{}", process::id(), n)); - fs::create_dir_all(&p).unwrap(); + fs::create_dir_all(&p).expect("dir creates"); p } fn bootstrap_snapshot(env: &dyn EnvTrait) -> ConfigSnapshot { - ConfigSnapshot::from(&ConfigService::bootstrap_parts(env, os_fs()).unwrap().0) + ConfigSnapshot::from( + &ConfigService::bootstrap_parts(env, os_fs()) + .expect("config bootstraps") + .0, + ) } /// Writable `HOME` and mock env: no `PATCH_HUB_CONFIG_PATH` (uses `HOME/.config/...`). @@ -127,7 +131,7 @@ fn config_fixture_json(root: &Path) -> String { "git_am_branch_prefix": "really-creative-prefix-", "stay_on_applied_branch": false }); - serde_json::to_string_pretty(&v).unwrap() + serde_json::to_string_pretty(&v).expect("config serializes") } #[test] @@ -175,7 +179,7 @@ fn bootstrap_with_default_values() { fn bootstrap_with_config_file() { let fixture_root = unique_test_dir("fixture"); let tmp_path = fixture_root.join("config.json"); - fs::write(&tmp_path, config_fixture_json(&fixture_root)).unwrap(); + fs::write(&tmp_path, config_fixture_json(&fixture_root)).expect("file writes"); let tmp_path_s = tmp_path.to_string_lossy().into_owned(); let home = unique_test_dir("home-cfg"); @@ -238,13 +242,17 @@ fn bootstrap_with_config_file() { HashSet::from([&"linux".to_string(), &"amd-gfx".to_string()]), config.kernel_trees() ); - let linux = config.get_kernel_tree("linux").unwrap(); + let linux = config.get_kernel_tree("linux").expect("kernel tree loads"); assert_eq!(linux.path().as_str(), "/home/user/linux"); assert_eq!(linux.branch().as_str(), "master"); assert!(config.get_kernel_tree("invalid-id").is_none()); assert_eq!( "linux", - config.target_kernel_tree().as_ref().unwrap().as_str() + config + .target_kernel_tree() + .as_ref() + .expect("target kernel tree is set") + .as_str() ); assert_eq!( "--foo-bar foobar -s -n -o -r -l -a -x", @@ -328,7 +336,7 @@ fn bootstrap_config_precedence() { let fixture_root = unique_test_dir("prec"); let tmp_path = fixture_root.join("config.json"); - fs::write(&tmp_path, config_fixture_json(&fixture_root)).unwrap(); + fs::write(&tmp_path, config_fixture_json(&fixture_root)).expect("file writes"); let tmp_path_s = tmp_path.to_string_lossy().into_owned(); let home_s = home.to_string_lossy().into_owned(); @@ -396,7 +404,7 @@ fn deserialize_config_state_with_missing_field() { "max_log_age": 500 }); - let state: ConfigState = serde_json::from_value(json_data).unwrap(); + let state: ConfigState = serde_json::from_value(json_data).expect("json parses"); assert_eq!(state.page_size, 30); assert_eq!(state.max_log_age(), 500); @@ -629,13 +637,13 @@ fn validate_update_rejects_invalid_kw_deploy_bools() { ) .unwrap_err(); if expect_reboot_err { - let raw = reboot.unwrap(); + let raw = reboot.expect("reboot value is set"); assert!( matches!(err, ConfigError::InvalidKwRebootAfterDeploy(ref s) if s == raw), "unexpected error for reboot={reboot:?}: {err:?}" ); } else { - let raw = force.unwrap(); + let raw = force.expect("force value is set"); assert!( matches!(err, ConfigError::InvalidKwDeployForce(ref s) if s == raw), "unexpected error for force={force:?}: {err:?}" @@ -677,7 +685,7 @@ fn validate_update_accepts_existing_target_kernel_tree() { &os_fs(), &state, ) - .unwrap(); + .expect("update validates"); assert_eq!(Some(Some("linux".to_string())), update.target_kernel_tree); } @@ -693,7 +701,7 @@ fn validate_update_unsets_target_kernel_tree_on_empty_string() { &os_fs(), &state, ) - .unwrap(); + .expect("update validates"); assert_eq!(Some(None), update.target_kernel_tree); } @@ -746,7 +754,7 @@ fn apply_update_sets_and_unsets_target_kernel_tree() { fn validate_update_rejects_cache_dir_that_is_existing_file() { let root = unique_test_dir("not-a-dir"); let blocking = root.join("blocking-file"); - fs::write(&blocking, b"x").unwrap(); + fs::write(&blocking, b"x").expect("file writes"); let err = ConfigService::validate_update( ConfigUpdateDraft { cache_dir: Some(blocking.to_string_lossy().into_owned()), @@ -777,11 +785,11 @@ fn json_config_repository_save_creates_parent_and_leaves_no_tmp_stale() { let repo = JsonConfigRepository::new(&mock, os_fs()); let state = ConfigState::new_with_defaults(&mock); - repo.save(&state).unwrap(); + repo.save(&state).expect("config saves"); assert!(cfg_path.is_file()); - let parent = cfg_path.parent().unwrap(); - let tmp_left = fs::read_dir(parent).unwrap().any(|e| { + let parent = cfg_path.parent().expect("path has a parent"); + let tmp_left = fs::read_dir(parent).expect("dir reads").any(|e| { e.ok() .is_some_and(|x| x.file_name().to_string_lossy().ends_with(".tmp")) }); @@ -794,7 +802,7 @@ fn json_config_repository_save_creates_parent_and_leaves_no_tmp_stale() { #[tokio::test] async fn validate_and_apply_persists_to_config_file() { let (env, home) = default_env(); - let (state, repo) = ConfigService::bootstrap_parts(&env, os_fs()).unwrap(); + let (state, repo) = ConfigService::bootstrap_parts(&env, os_fs()).expect("config bootstraps"); let handle = ConfigActor::spawn(state, repo); let cfg_path = home.join(DEFAULT_CONFIG_PATH_SUFFIX); @@ -804,11 +812,11 @@ async fn validate_and_apply_persists_to_config_file() { ..Default::default() }) .await - .unwrap(); + .expect("update applies"); assert_eq!(snapshot.page_size(), 77); - let raw = fs::read_to_string(&cfg_path).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let raw = fs::read_to_string(&cfg_path).expect("file reads"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("config parses"); assert_eq!(parsed["page_size"], 77); handle.shutdown().await; } @@ -816,7 +824,8 @@ async fn validate_and_apply_persists_to_config_file() { #[tokio::test] async fn validate_and_apply_persists_target_kernel_tree() { let (env, home) = default_env(); - let (mut state, repo) = ConfigService::bootstrap_parts(&env, os_fs()).unwrap(); + let (mut state, repo) = + ConfigService::bootstrap_parts(&env, os_fs()).expect("config bootstraps"); state.kernel_trees.insert( "linux".into(), sample_kernel_tree("/home/user/linux", "master"), @@ -830,11 +839,11 @@ async fn validate_and_apply_persists_target_kernel_tree() { ..Default::default() }) .await - .unwrap(); + .expect("update applies"); assert_eq!(Some("linux"), snapshot.target_kernel_tree().as_deref()); - let raw = fs::read_to_string(&cfg_path).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let raw = fs::read_to_string(&cfg_path).expect("file reads"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("config parses"); assert_eq!(parsed["target_kernel_tree"], "linux"); handle @@ -843,9 +852,9 @@ async fn validate_and_apply_persists_target_kernel_tree() { ..Default::default() }) .await - .unwrap(); - let raw = fs::read_to_string(&cfg_path).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); + .expect("update applies"); + let raw = fs::read_to_string(&cfg_path).expect("file reads"); + let parsed: serde_json::Value = serde_json::from_str(&raw).expect("config parses"); assert!(parsed["target_kernel_tree"].is_null()); handle.shutdown().await; } @@ -853,7 +862,8 @@ async fn validate_and_apply_persists_target_kernel_tree() { #[tokio::test] async fn invalid_target_kernel_tree_keeps_existing_state() { let (env, _home) = default_env(); - let (mut state, repo) = ConfigService::bootstrap_parts(&env, os_fs()).unwrap(); + let (mut state, repo) = + ConfigService::bootstrap_parts(&env, os_fs()).expect("config bootstraps"); state.kernel_trees.insert( "linux".into(), sample_kernel_tree("/home/user/linux", "master"), @@ -877,7 +887,7 @@ async fn invalid_target_kernel_tree_keeps_existing_state() { handle .get_snapshot() .await - .unwrap() + .expect("snapshot loads") .target_kernel_tree() .as_deref() ); diff --git a/src/infrastructure/env/tests.rs b/src/infrastructure/env/tests.rs index 527dce4..6b18cb1 100644 --- a/src/infrastructure/env/tests.rs +++ b/src/infrastructure/env/tests.rs @@ -6,7 +6,7 @@ fn var_returns_existing_variable() { // PATH is universally set let result = env.var("PATH"); assert!(result.is_ok(), "PATH should be set"); - assert!(!result.unwrap().is_empty()); + assert!(!result.expect("env var reads").is_empty()); } #[test] diff --git a/src/infrastructure/file_system/tests.rs b/src/infrastructure/file_system/tests.rs index e1a464e..3e75c1b 100644 --- a/src/infrastructure/file_system/tests.rs +++ b/src/infrastructure/file_system/tests.rs @@ -11,7 +11,7 @@ impl TempDir { fn new(test_name: &str) -> Self { let dir = env::temp_dir().join(format!("patch_hub_fs_test_{}_{test_name}", process::id())); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); Self(dir) } @@ -30,10 +30,10 @@ impl Drop for TempDir { fn read_to_string_returns_file_contents() { let dir = TempDir::new("read_to_string"); let file_path = dir.path().join("test.txt"); - fs::write(&file_path, "hello world").unwrap(); + fs::write(&file_path, "hello world").expect("file writes"); let fs = OsFileSystem; - let contents = fs.read_to_string(&file_path).unwrap(); + let contents = fs.read_to_string(&file_path).expect("file reads"); assert_eq!(contents, "hello world"); } @@ -50,9 +50,9 @@ fn write_creates_file_with_contents() { let file_path = dir.path().join("write_test.txt"); let fs = OsFileSystem; - fs.write(&file_path, b"test data").unwrap(); + fs.write(&file_path, b"test data").expect("file writes"); - let contents = fs::read_to_string(&file_path).unwrap(); + let contents = fs::read_to_string(&file_path).expect("file reads"); assert_eq!(contents, "test data"); } @@ -62,7 +62,7 @@ fn create_dir_all_creates_nested_directories() { let nested = dir.path().join("a/b/c"); let fs = OsFileSystem; - fs.create_dir_all(&nested).unwrap(); + fs.create_dir_all(&nested).expect("dir creates"); assert!(nested.is_dir()); } @@ -71,7 +71,7 @@ fn create_dir_all_creates_nested_directories() { fn exists_returns_true_for_existing_path() { let dir = TempDir::new("exists"); let file_path = dir.path().join("exists_test.txt"); - fs::write(&file_path, "").unwrap(); + fs::write(&file_path, "").expect("file writes"); let fs = OsFileSystem; assert!(fs.exists(&file_path)); @@ -83,7 +83,7 @@ fn exists_returns_true_for_existing_path() { fn is_file_distinguishes_files_from_dirs() { let dir = TempDir::new("is_file"); let file_path = dir.path().join("a_file.txt"); - fs::write(&file_path, "").unwrap(); + fs::write(&file_path, "").expect("file writes"); let fs = OsFileSystem; assert!(fs.is_file(&file_path)); @@ -94,7 +94,7 @@ fn is_file_distinguishes_files_from_dirs() { fn is_dir_distinguishes_dirs_from_files() { let dir = TempDir::new("is_dir"); let file_path = dir.path().join("a_file.txt"); - fs::write(&file_path, "").unwrap(); + fs::write(&file_path, "").expect("file writes"); let fs = OsFileSystem; assert!(fs.is_dir(dir.path())); @@ -104,13 +104,13 @@ fn is_dir_distinguishes_dirs_from_files() { #[test] fn read_dir_lists_immediate_children_sorted() { let dir = TempDir::new("read_dir"); - fs::write(dir.path().join("b.txt"), "").unwrap(); - fs::create_dir(dir.path().join("a_sub")).unwrap(); - fs::create_dir(dir.path().join("a_sub/nested")).unwrap(); - fs::write(dir.path().join("c.txt"), "").unwrap(); + fs::write(dir.path().join("b.txt"), "").expect("file writes"); + fs::create_dir(dir.path().join("a_sub")).expect("dir creates"); + fs::create_dir(dir.path().join("a_sub/nested")).expect("dir creates"); + fs::write(dir.path().join("c.txt"), "").expect("file writes"); let fs = OsFileSystem; - let entries = fs.read_dir(dir.path()).unwrap(); + let entries = fs.read_dir(dir.path()).expect("dir reads"); assert_eq!( entries, @@ -134,13 +134,13 @@ fn rename_moves_file() { let dir = TempDir::new("rename"); let src = dir.path().join("src.txt"); let dst = dir.path().join("dst.txt"); - fs::write(&src, "rename me").unwrap(); + fs::write(&src, "rename me").expect("file writes"); let fs = OsFileSystem; - fs.rename(&src, &dst).unwrap(); + fs.rename(&src, &dst).expect("path renames"); assert!(!src.exists()); - assert_eq!(fs::read_to_string(&dst).unwrap(), "rename me"); + assert_eq!(fs::read_to_string(&dst).expect("file reads"), "rename me"); } #[test] @@ -151,11 +151,11 @@ fn create_writer_writes_to_file() { let file_path = dir.path().join("writer_test.txt"); let fs = OsFileSystem; - let mut writer = fs.create_writer(&file_path).unwrap(); - writer.write_all(b"via writer").unwrap(); + let mut writer = fs.create_writer(&file_path).expect("writer creates"); + writer.write_all(b"via writer").expect("file writes"); drop(writer); - let contents = fs::read_to_string(&file_path).unwrap(); + let contents = fs::read_to_string(&file_path).expect("file reads"); assert_eq!(contents, "via writer"); } @@ -165,12 +165,15 @@ fn open_bufreader_reads_file() { let dir = TempDir::new("open_bufreader"); let file_path = dir.path().join("bufreader_test.txt"); - fs::write(&file_path, "line1\nline2\n").unwrap(); + fs::write(&file_path, "line1\nline2\n").expect("file writes"); let fs = OsFileSystem; - let reader = fs.open_bufreader(&file_path).unwrap(); + let reader = fs.open_bufreader(&file_path).expect("bufreader opens"); - let lines = reader.lines().map(|l| l.unwrap()).collect::>(); + let lines = reader + .lines() + .map(|l| l.expect("line reads")) + .collect::>(); assert_eq!(lines, vec!["line1", "line2"]); } @@ -178,10 +181,10 @@ fn open_bufreader_reads_file() { fn metadata_returns_file_metadata() { let dir = TempDir::new("metadata"); let file_path = dir.path().join("meta_test.txt"); - fs::write(&file_path, "some content").unwrap(); + fs::write(&file_path, "some content").expect("file writes"); let fs = OsFileSystem; - let meta = fs.metadata(&file_path).unwrap(); + let meta = fs.metadata(&file_path).expect("metadata reads"); assert!(meta.is_file()); assert_eq!(meta.len(), 12); } @@ -197,12 +200,13 @@ fn metadata_returns_error_for_missing_path() { fn read_tail_returns_the_whole_file_when_it_fits() { let dir = TempDir::new("read_tail_fits"); let file_path = dir.path().join("log.txt"); - fs::write(&file_path, "line1\nline2\n").unwrap(); + fs::write(&file_path, "line1\nline2\n").expect("file writes"); let fs = OsFileSystem; assert_eq!( "line1\nline2\n", - fs.read_tail_to_string(&file_path, 64).unwrap() + fs.read_tail_to_string(&file_path, 64) + .expect("log tail reads") ); } @@ -211,30 +215,40 @@ fn read_tail_drops_a_partial_first_line_when_seeking() { let dir = TempDir::new("read_tail_partial"); let file_path = dir.path().join("log.txt"); // 14 bytes; an 8-byte window starts mid-"BBBB". - fs::write(&file_path, "AAAA\nBBBB\nCCCC").unwrap(); + fs::write(&file_path, "AAAA\nBBBB\nCCCC").expect("file writes"); let fs = OsFileSystem; - assert_eq!("CCCC", fs.read_tail_to_string(&file_path, 8).unwrap()); + assert_eq!( + "CCCC", + fs.read_tail_to_string(&file_path, 8) + .expect("log tail reads") + ); } #[test] fn read_tail_keeps_a_window_with_no_newline() { let dir = TempDir::new("read_tail_nonewline"); let file_path = dir.path().join("log.txt"); - fs::write(&file_path, "abcdefghijklmnopqrst").unwrap(); + fs::write(&file_path, "abcdefghijklmnopqrst").expect("file writes"); let fs = OsFileSystem; - assert_eq!("mnopqrst", fs.read_tail_to_string(&file_path, 8).unwrap()); + assert_eq!( + "mnopqrst", + fs.read_tail_to_string(&file_path, 8) + .expect("log tail reads") + ); } #[test] fn read_tail_decodes_invalid_utf8_lossily() { let dir = TempDir::new("read_tail_utf8"); let file_path = dir.path().join("log.txt"); - fs::write(&file_path, b"ok\n\xff\xfeworld").unwrap(); + fs::write(&file_path, b"ok\n\xff\xfeworld").expect("file writes"); let fs = OsFileSystem; - let text = fs.read_tail_to_string(&file_path, 64).unwrap(); + let text = fs + .read_tail_to_string(&file_path, 64) + .expect("log tail reads"); assert!(text.starts_with("ok\n")); assert!(text.contains('\u{FFFD}')); assert!(text.ends_with("world")); diff --git a/src/infrastructure/net/tests.rs b/src/infrastructure/net/tests.rs index 2409fcd..f7d6771 100644 --- a/src/infrastructure/net/tests.rs +++ b/src/infrastructure/net/tests.rs @@ -8,7 +8,7 @@ fn mock_net_client_returns_configured_response() { let result = mock.request(HttpMethod::Get, "https://example.com"); assert!(result.is_ok()); - assert_eq!("response", result.unwrap()); + assert_eq!("response", result.expect("request completes")); } #[test] diff --git a/src/infrastructure/process/fake.rs b/src/infrastructure/process/fake.rs index aab609b..8a4e3c7 100644 --- a/src/infrastructure/process/fake.rs +++ b/src/infrastructure/process/fake.rs @@ -57,14 +57,14 @@ impl FakeControl { let mut file = OpenOptions::new() .append(true) .open(&self.log_path) - .unwrap(); - file.write_all(contents).unwrap(); + .expect("file opens"); + file.write_all(contents).expect("file writes"); } /// Unblock `wait()`, reporting exit with `exit_code`. A terminal state is /// terminal: a process already finished or killed does not exit later. pub fn finish(&self, exit_code: i32) { - let mut state = self.state.lock().unwrap(); + let mut state = self.state.lock().expect("state locks"); if state.raw_status.is_none() { state.raw_status = Some(exit_code << 8); drop(state); @@ -73,11 +73,11 @@ impl FakeControl { } pub fn was_killed(&self) -> bool { - self.state.lock().unwrap().killed + self.state.lock().expect("state locks").killed } pub fn was_force_killed(&self) -> bool { - self.state.lock().unwrap().force_killed + self.state.lock().expect("state locks").force_killed } } @@ -124,7 +124,7 @@ impl FakeProcess { pub fn spawned(&self) -> Vec { self.spawns .lock() - .unwrap() + .expect("spawns locks") .iter() .map(|spawn| spawn.record.clone()) .collect() @@ -136,7 +136,7 @@ impl FakeProcess { pub fn last_child(&self) -> Arc { self.spawns .lock() - .unwrap() + .expect("spawns locks") .last() .map(|spawn| spawn.control.clone()) .expect("FakeProcess::last_child called before any spawn") @@ -169,7 +169,7 @@ impl ProcessTrait for FakeProcess { notify: Notify::new(), log_path: log_path.to_path_buf(), }); - self.spawns.lock().unwrap().push(FakeSpawn { + self.spawns.lock().expect("spawns locks").push(FakeSpawn { record: SpawnRecord { program: cmd.program.clone(), args: cmd.args.clone(), @@ -196,7 +196,7 @@ impl RunningProcess for FakeRunningProcess { // racing the check is not lost because Notify stores one // permit. let finished = { - let state = self.control.state.lock().unwrap(); + let state = self.control.state.lock().expect("state locks"); state.raw_status.map(|raw| (raw, state.fails_wait)) }; if let Some((raw, fails_wait)) = finished { @@ -211,7 +211,7 @@ impl RunningProcess for FakeRunningProcess { } fn kill(&mut self) -> Result<(), ProcessError> { - let mut state = self.control.state.lock().unwrap(); + let mut state = self.control.state.lock().expect("state locks"); // Mirrors the real kill()'s ESRCH tolerance: killing an already-dead // process is a successful no-op, not a kill. if state.raw_status.is_none() { @@ -226,7 +226,7 @@ impl RunningProcess for FakeRunningProcess { } fn force_kill(&mut self) -> Result<(), ProcessError> { - let mut state = self.control.state.lock().unwrap(); + let mut state = self.control.state.lock().expect("state locks"); // SIGKILL cannot be ignored: even a SIGTERM-stubborn process dies. if state.raw_status.is_none() { state.force_killed = true; diff --git a/src/infrastructure/process/tests.rs b/src/infrastructure/process/tests.rs index 93b122d..2e660f6 100644 --- a/src/infrastructure/process/tests.rs +++ b/src/infrastructure/process/tests.rs @@ -28,7 +28,7 @@ impl TempDir { process::id() )); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); Self(dir) } @@ -82,15 +82,17 @@ async fn spawn_returns_while_process_still_running() { let log = dir.path().join("job.log"); let cmd = ShellCommand::new("sh").args(["-c", "sleep 0.5; echo done"]); - let mut process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = OsProcess + .spawn(&cmd, dir.path(), &log) + .expect("process spawns"); // spawn returned while the child was still inside its sleep - let early_log = fs::read_to_string(&log).unwrap(); + let early_log = fs::read_to_string(&log).expect("file reads"); assert!(!early_log.contains("done")); - let status = process.wait().await.unwrap(); + let status = process.wait().await.expect("process exits"); assert!(status.success()); - let final_log = fs::read_to_string(&log).unwrap(); + let final_log = fs::read_to_string(&log).expect("file reads"); assert!(final_log.contains("done")); } @@ -100,8 +102,10 @@ async fn wait_returns_exit_code() { let log = dir.path().join("job.log"); let cmd = ShellCommand::new("sh").args(["-c", "exit 42"]); - let mut process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); - let status = process.wait().await.unwrap(); + let mut process = OsProcess + .spawn(&cmd, dir.path(), &log) + .expect("process spawns"); + let status = process.wait().await.expect("process exits"); assert!(!status.success()); assert_eq!(status.code(), Some(42)); @@ -114,12 +118,14 @@ async fn log_file_grows_incrementally_with_stdout_and_stderr() { let cmd = ShellCommand::new("sh").args(["-c", "echo first; echo errline >&2; sleep 1; echo second"]); - let mut process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = OsProcess + .spawn(&cmd, dir.path(), &log) + .expect("process spawns"); // output reaches the file while the process is still running, not at exit let saw_partial_log = wait_for( || { - let contents = fs::read_to_string(&log).unwrap(); + let contents = fs::read_to_string(&log).expect("file reads"); contents.contains("first") && contents.contains("errline") && !contents.contains("second") @@ -129,9 +135,9 @@ async fn log_file_grows_incrementally_with_stdout_and_stderr() { .await; assert!(saw_partial_log); - let status = process.wait().await.unwrap(); + let status = process.wait().await.expect("process exits"); assert!(status.success()); - let contents = fs::read_to_string(&log).unwrap(); + let contents = fs::read_to_string(&log).expect("file reads"); assert!(contents.contains("first")); assert!(contents.contains("errline")); assert!(contents.contains("second")); @@ -143,12 +149,14 @@ async fn spawn_runs_in_given_cwd() { let log = dir.path().join("job.log"); let cmd = ShellCommand::new("pwd"); - let mut process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); - let status = process.wait().await.unwrap(); + let mut process = OsProcess + .spawn(&cmd, dir.path(), &log) + .expect("process spawns"); + let status = process.wait().await.expect("process exits"); assert!(status.success()); - let out = fs::read_to_string(&log).unwrap(); - let expected = dir.path().canonicalize().unwrap(); + let out = fs::read_to_string(&log).expect("file reads"); + let expected = dir.path().canonicalize().expect("path canonicalizes"); assert_eq!(out.trim(), expected.to_string_lossy()); } @@ -160,15 +168,17 @@ async fn kill_terminates_process_group() { let script = format!("sleep 60 & echo $! > \"{}\"; wait", sidecar.display()); let cmd = ShellCommand::new("sh").args(["-c", &script]); - let mut process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = OsProcess + .spawn(&cmd, dir.path(), &log) + .expect("process spawns"); let grandchild_pid = await_sidecar_pid(&sidecar).await; - process.kill().unwrap(); + process.kill().expect("process kills"); let status = time::timeout(Duration::from_secs(2), process.wait()) .await .expect("wait must complete shortly after kill") - .unwrap(); + .expect("process exits"); // Signal death vs. exit code 128+SIGTERM is shell-dependent; what matters // is the job did not succeed. assert!(!status.success()); @@ -183,11 +193,13 @@ async fn kill_after_successful_exit_is_ok() { let log = dir.path().join("job.log"); let cmd = ShellCommand::new("sh").args(["-c", "exit 0"]); - let mut process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); - process.wait().await.unwrap(); + let mut process = OsProcess + .spawn(&cmd, dir.path(), &log) + .expect("process spawns"); + process.wait().await.expect("process exits"); - process.kill().unwrap(); - process.kill().unwrap(); + process.kill().expect("process kills"); + process.kill().expect("process kills"); } #[tokio::test] @@ -210,7 +222,9 @@ async fn dropped_unreaped_process_group_is_killed() { let script = format!("sleep 60 & echo $! > \"{}\"; wait", sidecar.display()); let cmd = ShellCommand::new("sh").args(["-c", &script]); - let process = OsProcess.spawn(&cmd, dir.path(), &log).unwrap(); + let process = OsProcess + .spawn(&cmd, dir.path(), &log) + .expect("process spawns"); let grandchild_pid = await_sidecar_pid(&sidecar).await; @@ -228,8 +242,8 @@ async fn running_process_is_dyn_compatible_and_mockable() { mock.expect_kill().returning(|| Ok(())); let mut process: Box = Box::new(mock); - process.kill().unwrap(); - let status = process.wait().await.unwrap(); + process.kill().expect("process kills"); + let status = process.wait().await.expect("process exits"); assert!(status.success()); } @@ -240,7 +254,7 @@ async fn fake_process_records_spawn_and_simulates_run() { let fake = FakeProcess::new(); let cmd = ShellCommand::new("kw").args(["build", "--alert=n"]); - let mut process = fake.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = fake.spawn(&cmd, dir.path(), &log).expect("process spawns"); assert_eq!(fake.spawned().len(), 1); let record = &fake.spawned()[0]; @@ -256,11 +270,11 @@ async fn fake_process_records_spawn_and_simulates_run() { let early_wait = time::timeout(Duration::from_millis(50), process.wait()).await; assert!(early_wait.is_err()); - let log_so_far = fs::read_to_string(&log).unwrap(); + let log_so_far = fs::read_to_string(&log).expect("file reads"); assert_eq!(log_so_far, "partial output\n"); control.finish(0); - let status = process.wait().await.unwrap(); + let status = process.wait().await.expect("process exits"); assert!(status.success()); } @@ -271,10 +285,10 @@ async fn fake_finish_with_nonzero_code_yields_that_code() { let fake = FakeProcess::new(); let cmd = ShellCommand::new("kw").arg("build"); - let mut process = fake.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = fake.spawn(&cmd, dir.path(), &log).expect("process spawns"); fake.last_child().finish(42); - let status = process.wait().await.unwrap(); + let status = process.wait().await.expect("process exits"); assert!(!status.success()); assert_eq!(status.code(), Some(42)); } @@ -286,12 +300,12 @@ async fn fake_kill_makes_wait_return_signal_status() { let fake = FakeProcess::new(); let cmd = ShellCommand::new("kw").arg("build"); - let mut process = fake.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = fake.spawn(&cmd, dir.path(), &log).expect("process spawns"); - process.kill().unwrap(); + process.kill().expect("process kills"); assert!(fake.last_child().was_killed()); - let status = process.wait().await.unwrap(); + let status = process.wait().await.expect("process exits"); assert!(status.code().is_none()); } @@ -302,13 +316,13 @@ async fn fake_finish_after_kill_keeps_signal_status() { let fake = FakeProcess::new(); let cmd = ShellCommand::new("kw").arg("build"); - let mut process = fake.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = fake.spawn(&cmd, dir.path(), &log).expect("process spawns"); - process.kill().unwrap(); + process.kill().expect("process kills"); // a killed process cannot exit 0 later; finish() must not resurrect it fake.last_child().finish(0); - let status = process.wait().await.unwrap(); + let status = process.wait().await.expect("process exits"); assert!(status.code().is_none()); assert!(fake.last_child().was_killed()); } @@ -320,13 +334,13 @@ async fn fake_kill_after_finish_is_a_no_op_success() { let fake = FakeProcess::new(); let cmd = ShellCommand::new("kw").arg("build"); - let mut process = fake.spawn(&cmd, dir.path(), &log).unwrap(); + let mut process = fake.spawn(&cmd, dir.path(), &log).expect("process spawns"); fake.last_child().finish(0); - process.kill().unwrap(); + process.kill().expect("process kills"); assert!(!fake.last_child().was_killed()); - let status = process.wait().await.unwrap(); + let status = process.wait().await.expect("process exits"); assert!(status.success()); } diff --git a/src/infrastructure/shell/tests.rs b/src/infrastructure/shell/tests.rs index 559213c..85cc9ab 100644 --- a/src/infrastructure/shell/tests.rs +++ b/src/infrastructure/shell/tests.rs @@ -4,9 +4,14 @@ use super::{OsShell, ShellCommand, ShellTrait}; fn execute_captures_stdout() { let shell = OsShell; let cmd = ShellCommand::new("echo").arg("hello shell"); - let out = shell.execute(&cmd).unwrap(); + let out = shell.execute(&cmd).expect("terminal executes"); assert!(out.success); - assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "hello shell"); + assert_eq!( + String::from_utf8(out.stdout) + .expect("bytes are utf-8") + .trim(), + "hello shell" + ); } #[test] @@ -14,7 +19,7 @@ fn execute_captures_stderr() { let shell = OsShell; // `ls` on a nonexistent path writes to stderr and exits nonzero let cmd = ShellCommand::new("ls").arg("/nonexistent_path_patch_hub_test"); - let out = shell.execute(&cmd).unwrap(); + let out = shell.execute(&cmd).expect("terminal executes"); assert!(!out.success); assert!(!out.stderr.is_empty()); } @@ -23,7 +28,9 @@ fn execute_captures_stderr() { fn execute_with_stdin_pipes_input() { let shell = OsShell; let cmd = ShellCommand::new("cat"); - let out = shell.execute_with_stdin(&cmd, b"piped input").unwrap(); + let out = shell + .execute_with_stdin(&cmd, b"piped input") + .expect("command runs"); assert!(out.success); assert_eq!(out.stdout, b"piped input"); } @@ -32,7 +39,7 @@ fn execute_with_stdin_pipes_input() { fn execute_with_stdin_on_failing_command_returns_error() { let shell = OsShell; let cmd = ShellCommand::new("false"); - let out = shell.execute_with_stdin(&cmd, b"").unwrap(); + let out = shell.execute_with_stdin(&cmd, b"").expect("command runs"); assert!(!out.success); } diff --git a/src/input/actor.rs b/src/input/actor.rs index efcf46e..242fd9c 100644 --- a/src/input/actor.rs +++ b/src/input/actor.rs @@ -188,7 +188,10 @@ mod tests { let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); - input_handle.subscribe_app(sub_tx).await.unwrap(); + input_handle + .subscribe_app(sub_tx) + .await + .expect("app subscribes"); let received = sub_rx.recv().await; assert_eq!(received, Some(InputEvent::NavigateDown)); @@ -212,13 +215,16 @@ mod tests { let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); - input_handle.subscribe_app(sub_tx).await.unwrap(); + input_handle + .subscribe_app(sub_tx) + .await + .expect("app subscribes"); // In MailingListSelection, Esc maps to Quit. Switch to PatchsetDetails // so Esc maps to Back instead. input_handle .update_context(details_context()) .await - .unwrap(); + .expect("context updates"); let received = sub_rx.recv().await; assert_eq!(received, Some(InputEvent::Back)); @@ -246,7 +252,10 @@ mod tests { let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); - input_handle.subscribe_app(sub_tx).await.unwrap(); + input_handle + .subscribe_app(sub_tx) + .await + .expect("app subscribes"); // Only NavigateDown arrives; the F6 event is silently discarded. let received = sub_rx.recv().await; @@ -261,8 +270,11 @@ mod tests { let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); - input_handle.subscribe_app(sub_tx).await.unwrap(); - input_handle.shutdown().await.unwrap(); + input_handle + .subscribe_app(sub_tx) + .await + .expect("app subscribes"); + input_handle.shutdown().await.expect("actor shuts down"); // When the actor stops it drops the subscriber Sender, closing the // channel. recv() returns None once all senders are gone. @@ -283,7 +295,10 @@ mod tests { let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); - input_handle.subscribe_app(sub_tx).await.unwrap(); + input_handle + .subscribe_app(sub_tx) + .await + .expect("app subscribes"); // When the pump stops due to the error, it drops event_tx. // InputActor sees event_rx close and stops, dropping the subscriber sender. @@ -308,7 +323,10 @@ mod tests { }; let (input_handle, _terminal_handle) = spawn_test_actor(session, context); let (sub_tx, mut sub_rx) = mpsc::channel::(8); - input_handle.subscribe_app(sub_tx).await.unwrap(); + input_handle + .subscribe_app(sub_tx) + .await + .expect("app subscribes"); let received = sub_rx.recv().await; assert_eq!(received, Some(InputEvent::ClosePopup)); diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index fa90783..36677eb 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -46,16 +46,16 @@ fn tmp_log_dir(test_name: &str) -> PathBuf { process::id() )); let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); dir } fn kernel_tree(path: &Path) -> KernelTree { serde_json::from_value(serde_json::json!({ - "path": path.to_str().unwrap(), + "path": path.to_str().expect("path is utf-8"), "branch": "master" })) - .unwrap() + .expect("json parses") } fn start_request() -> StartRequest { @@ -120,7 +120,10 @@ fn recording_shell( let calls_in_shell = Arc::clone(&calls); let mut shell = MockShellTrait::new(); shell.expect_execute().returning(move |cmd| { - calls_in_shell.lock().unwrap().push(command_parts(cmd)); + calls_in_shell + .lock() + .expect("calls in shell locks") + .push(command_parts(cmd)); let output = |stdout: &[u8], stderr: &[u8], success: bool| ShellOutput { stdout: stdout.to_vec(), stderr: stderr.to_vec(), @@ -161,7 +164,7 @@ impl GitStub { } pub(super) fn head(&self) -> String { - self.head.lock().unwrap().clone() + self.head.lock().expect("head locks").clone() } pub(super) fn shell(&self) -> MockShellTrait { @@ -187,18 +190,23 @@ impl GitStub { return Ok(output(stdout)); } if cmd.args.iter().any(|arg| arg == "switch") { - let branch = cmd.args.last().unwrap().clone(); - if fail_switch_to.lock().unwrap().as_deref() == Some(branch.as_str()) { + let branch = cmd.args.last().expect("iterator yields last").clone(); + if fail_switch_to + .lock() + .expect("fail switch to locks") + .as_deref() + == Some(branch.as_str()) + { return Ok(ShellOutput { stdout: Vec::new(), stderr: b"error: you need to resolve your current index first\n".to_vec(), success: false, }); } - *head.lock().unwrap() = branch; + *head.lock().expect("head locks") = branch; return Ok(output(b"")); } - let current = format!("{}\n", head.lock().unwrap()); + let current = format!("{}\n", head.lock().expect("head locks")); Ok(output(current.as_bytes())) }); shell @@ -211,7 +219,7 @@ impl GitStub { } fn fail_switches_to(&self, branch: Option<&str>) { - *self.fail_switch_to.lock().unwrap() = branch.map(str::to_string); + *self.fail_switch_to.lock().expect("fail switch to locks") = branch.map(str::to_string); } } @@ -434,7 +442,10 @@ pub(super) fn recording_history( .expect_apply_record_for_branch() .returning(move |_, _| Ok(apply_record.clone())); history.expect_record_build().returning(move |record| { - builds_in_store.lock().unwrap().push(record); + builds_in_store + .lock() + .expect("builds in store locks") + .push(record); Ok(()) }); (history, builds) @@ -509,7 +520,10 @@ async fn record_apply_writes_through_history_store() { MockEnvTrait::new(), ); - handle.record_apply(apply_record()).await.unwrap(); + handle + .record_apply(apply_record()) + .await + .expect("apply records"); handle.shutdown().await; } @@ -544,7 +558,7 @@ async fn get_status_reports_idle_before_any_job() { MockEnvTrait::new(), ); - let snapshot = handle.get_status().await.unwrap(); + let snapshot = handle.get_status().await.expect("status loads"); assert_eq!(KwJobStatus::Idle, snapshot.job); assert_eq!(None, snapshot.restore_branch); @@ -561,7 +575,7 @@ async fn watch_status_receiver_sees_current_snapshot() { MockEnvTrait::new(), ); - let receiver = handle.watch_status().await.unwrap(); + let receiver = handle.watch_status().await.expect("status watch opens"); assert_eq!(KwJobStatus::Idle, receiver.borrow().job); handle.shutdown().await; @@ -609,7 +623,10 @@ async fn get_readiness_composes_probes_and_head_branch() { }); let handle = spawn_test_actor("readiness", history, shell, fs, env); - let readiness = handle.get_readiness("mainline", &tree, None).await.unwrap(); + let readiness = handle + .get_readiness("mainline", &tree, None) + .await + .expect("readiness loads"); assert!(!readiness.kw_binary.available); assert_eq!(TreeReadiness::Missing, readiness.tree); @@ -671,7 +688,7 @@ async fn get_readiness_for_branch_looks_up_that_branch_not_head() { let readiness = handle .get_readiness("mainline", &tree, Some("patchset-x")) .await - .unwrap(); + .expect("readiness loads"); assert_eq!(Some("master".to_string()), readiness.current_branch); handle.shutdown().await; @@ -691,7 +708,7 @@ pub(super) async fn wait_for_terminal_status( if !matches!(status, KwJobStatus::Idle | KwJobStatus::Running { .. }) { return status; } - watch.changed().await.unwrap(); + watch.changed().await.expect("watch notifies"); } }) .await @@ -711,7 +728,7 @@ pub(super) async fn wait_for_running_phase( ) { return status; } - watch.changed().await.unwrap(); + watch.changed().await.expect("watch notifies"); } }) .await @@ -727,7 +744,7 @@ async fn start_build_replies_immediately_and_runs_in_background() { let result = time::timeout(Duration::from_secs(1), handle.start_build(start_request())) .await .expect("start_build must reply immediately"); - result.unwrap(); + result.expect("build starts"); let spawned = process.spawned(); assert_eq!(1, spawned.len()); @@ -736,7 +753,7 @@ async fn start_build_replies_immediately_and_runs_in_background() { assert_eq!(Path::new("/home/user/linux"), spawned[0].cwd); assert!(spawned[0].log_path.starts_with(&log_dir)); - let snapshot = handle.get_status().await.unwrap(); + let snapshot = handle.get_status().await.expect("status loads"); assert!( matches!( snapshot.job, @@ -751,7 +768,7 @@ async fn start_build_replies_immediately_and_runs_in_background() { ); process.last_child().finish(0); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); let status = wait_for_terminal_status(&mut watch).await; assert!( matches!( @@ -765,7 +782,7 @@ async fn start_build_replies_immediately_and_runs_in_background() { ); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -783,7 +800,7 @@ async fn start_build_merges_extra_args_into_the_spawned_argv() { .into_iter() .map(String::from) .collect(); - handle.start_build(request).await.unwrap(); + handle.start_build(request).await.expect("build starts"); let spawned = process.spawned(); // Reserved options are stripped: the user's --alert and --save-log-to @@ -795,29 +812,35 @@ async fn start_build_merges_extra_args_into_the_spawned_argv() { process.last_child().finish(0); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] async fn second_start_while_running_is_refused() { let (handle, process, log_dir) = spawn_job_actor("busy"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); let second = handle.start_build(start_request()).await; assert!(matches!(second, Err(KwStartError::JobAlreadyRunning))); process.last_child().finish(0); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] async fn failed_build_reports_exit_code_and_log_path() { let (handle, process, log_dir) = spawn_job_actor("failed"); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().write_log( b" CC init/main.o\n\ init/main.c:1691:2: error: #error broken\n\ @@ -848,21 +871,24 @@ async fn failed_build_reports_exit_code_and_log_path() { } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] async fn cancel_kills_process_group_and_reports_cancelled() { let (handle, process, log_dir) = spawn_job_actor("cancel"); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); // The ack is immediate: process death is observed via the status, // not the reply. time::timeout(Duration::from_secs(1), handle.cancel()) .await .expect("cancel must ack immediately") - .unwrap(); + .expect("job cancels"); assert!(process.last_child().was_killed()); let status = wait_for_terminal_status(&mut watch).await; @@ -880,19 +906,22 @@ async fn cancel_kills_process_group_and_reports_cancelled() { } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] async fn shutdown_kills_running_job() { let (handle, process, log_dir) = spawn_job_actor("shutdown-kill"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); // shutdown() returns only after the kill escalation has completed. handle.shutdown().await; assert!(process.last_child().was_killed()); - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -903,14 +932,20 @@ async fn spawn_failure_refuses_start_and_stays_idle() { let err = handle.start_build(start_request()).await.unwrap_err(); assert!(matches!(err, KwStartError::Spawn(_))); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); // A refused start must leave the actor able to accept a later one. process.refuse_spawns(false); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -933,11 +968,14 @@ async fn start_build_refused_when_kw_binary_missing() { let err = handle.start_build(start_request()).await.unwrap_err(); assert!(matches!(err, KwStartError::KwBinaryMissing)); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -977,11 +1015,14 @@ async fn start_build_refused_when_tree_not_ready() { err, KwStartError::TreeNotReady(TreeReadiness::MissingKwDir) )); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -991,12 +1032,15 @@ async fn start_build_allowed_when_kw_version_below_floor() { let (shell, _calls) = recording_shell(b"kw, version beta-0.9\n", CLEAN_STATUS, SWITCH_OK); let (handle, process, log_dir) = spawn_job_actor_with_mocks("version-below", shell, ready_fs()); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); assert_eq!(1, process.spawned().len()); process.last_child().finish(0); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1005,10 +1049,13 @@ async fn start_build_switches_to_requested_branch_before_spawning() { let (handle, process, log_dir) = spawn_job_actor_with_mocks("checkout-order", shell, ready_fs()); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); { - let calls = calls.lock().unwrap(); + let calls = calls.lock().expect("calls locks"); let git_position = |subcommand: &str| { calls .iter() @@ -1056,7 +1103,7 @@ async fn start_build_switches_to_requested_branch_before_spawning() { process.last_child().finish(0); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1069,17 +1116,20 @@ async fn start_build_refused_when_worktree_is_dirty() { let err = handle.start_build(start_request()).await.unwrap_err(); assert!(matches!(err, KwStartError::DirtyWorktree)); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); assert!(process.spawned().is_empty()); // The refusal happens before any branch mutation. assert!(!calls .lock() - .unwrap() + .expect("calls locks") .iter() .any(|call| call.iter().any(|part| part == "switch"))); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1097,11 +1147,14 @@ async fn start_build_refused_when_git_state_is_unverifiable() { assert!(matches!(err, KwStartError::GitStateProbe(_))); assert!(err.to_string().contains("not a git repository")); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1120,11 +1173,14 @@ async fn start_build_refused_when_branch_switch_fails() { assert!(matches!(err, KwStartError::CheckoutFailed(_))); assert!(err.to_string().contains("did not match")); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1153,26 +1209,49 @@ async fn cancel_and_restore_without_job_are_immediate_errors() { async fn restore_switches_back_to_pre_job_branch_and_is_consumed() { let git = GitStub::on_branch("master"); let (handle, process, log_dir) = spawn_job_actor_with_mocks("restore", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); // The checkout policy left HEAD on the build branch. assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); assert_eq!( Some("master"), - handle.get_status().await.unwrap().restore_branch.as_deref() + handle + .get_status() + .await + .expect("status loads") + .restore_branch + .as_deref() ); process.last_child().finish(0); let status = wait_for_terminal_status(&mut watch).await; assert!(matches!(status, KwJobStatus::Succeeded { .. })); assert_eq!( Some("master"), - handle.get_status().await.unwrap().restore_branch.as_deref() + handle + .get_status() + .await + .expect("status loads") + .restore_branch + .as_deref() ); - handle.restore_previous_branch().await.unwrap(); + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); assert_eq!(git.head(), "master"); - assert_eq!(None, handle.get_status().await.unwrap().restore_branch); + assert_eq!( + None, + handle + .get_status() + .await + .expect("status loads") + .restore_branch + ); // A successful restore consumes the context: a second restore has // nothing to do. @@ -1180,7 +1259,7 @@ async fn restore_switches_back_to_pre_job_branch_and_is_consumed() { assert!(matches!(err, KwError::NoRecordedBranch)); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1189,20 +1268,26 @@ async fn restore_refused_while_job_is_running() { let (handle, process, log_dir) = spawn_job_actor_with_mocks("restore-running", git.shell(), ready_fs()); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); let err = handle.restore_previous_branch().await.unwrap_err(); assert!(matches!(err, KwError::JobRunning)); // The context survives the refusal: restore works once the job // ends. process.last_child().finish(0); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); let _ = wait_for_terminal_status(&mut watch).await; - handle.restore_previous_branch().await.unwrap(); + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); assert_eq!(git.head(), "master"); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1210,9 +1295,12 @@ async fn restore_refused_when_worktree_is_dirty() { let git = GitStub::on_branch("master"); let (handle, process, log_dir) = spawn_job_actor_with_mocks("restore-dirty", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let _ = wait_for_terminal_status(&mut watch).await; @@ -1223,16 +1311,24 @@ async fn restore_refused_when_worktree_is_dirty() { assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); assert_eq!( Some("master"), - handle.get_status().await.unwrap().restore_branch.as_deref() + handle + .get_status() + .await + .expect("status loads") + .restore_branch + .as_deref() ); // The context survives: clean the tree and retry. git.set_dirty(false); - handle.restore_previous_branch().await.unwrap(); + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); assert_eq!(git.head(), "master"); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1240,9 +1336,12 @@ async fn restore_failure_keeps_the_context_for_a_retry() { let git = GitStub::on_branch("master"); let (handle, process, log_dir) = spawn_job_actor_with_mocks("restore-fail", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let _ = wait_for_terminal_status(&mut watch).await; @@ -1252,11 +1351,14 @@ async fn restore_failure_keeps_the_context_for_a_retry() { assert!(err.to_string().contains("resolve your current index")); git.fail_switches_to(None); - handle.restore_previous_branch().await.unwrap(); + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); assert_eq!(git.head(), "master"); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1264,9 +1366,12 @@ async fn refused_start_does_not_clobber_the_restore_context() { let git = GitStub::on_branch("master"); let (handle, process, log_dir) = spawn_job_actor_with_mocks("restore-clobber", git.shell(), ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let _ = wait_for_terminal_status(&mut watch).await; assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); @@ -1281,11 +1386,14 @@ async fn refused_start_does_not_clobber_the_restore_context() { assert!(matches!(err, KwStartError::Spawn(_))); assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); - handle.restore_previous_branch().await.unwrap(); + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); assert_eq!(git.head(), "master"); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1319,7 +1427,10 @@ async fn log_dir_creation_failure_refuses_start_and_stays_idle() { let err = handle.start_build(start_request()).await.unwrap_err(); assert!(matches!(err, KwStartError::Fs(_))); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); assert!(process.spawned().is_empty()); // The switch happened and was rolled back: the tree is back on the // user's branch, and no restore target was recorded. @@ -1330,7 +1441,7 @@ async fn log_dir_creation_failure_refuses_start_and_stays_idle() { )); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1355,15 +1466,18 @@ async fn rollback_failure_keeps_the_spawn_refusal() { )); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] async fn cancel_racing_a_successful_exit_reports_success() { let (handle, process, log_dir) = spawn_job_actor("cancel-race"); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); // Whether the actor processes the exit or the cancel first is // timing-dependent, but the terminal status must be Succeeded @@ -1379,15 +1493,18 @@ async fn cancel_racing_a_successful_exit_reports_success() { assert!(!process.last_child().was_killed()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] async fn cancel_racing_a_failed_exit_reports_failure() { let (handle, process, log_dir) = spawn_job_actor("cancel-race-fail"); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(2); // Whether the actor processes the exit or the cancel first is // timing-dependent, but a plain exit (not signal-terminated) means @@ -1409,7 +1526,7 @@ async fn cancel_racing_a_failed_exit_reports_failure() { ); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } // Paused time: the runtime auto-advances through the grace-period @@ -1418,10 +1535,13 @@ async fn cancel_racing_a_failed_exit_reports_failure() { async fn cancel_escalates_to_sigkill_when_sigterm_is_ignored() { let (handle, process, log_dir) = spawn_job_actor("sigkill-escalation"); process.ignore_sigterm(true); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); - handle.cancel().await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); + handle.cancel().await.expect("job cancels"); let status = wait_for_terminal_status(&mut watch).await; assert!( @@ -1433,7 +1553,7 @@ async fn cancel_escalates_to_sigkill_when_sigterm_is_ignored() { assert!(child.was_force_killed()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1446,15 +1566,18 @@ async fn successful_build_writes_a_full_build_record() { env.expect_which().returning(|_| true); env }); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let status = wait_for_terminal_status(&mut watch).await; assert!(matches!(status, KwJobStatus::Succeeded { .. })); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); let record = &builds[0]; assert_eq!("mainline", record.kernel_tree_id); @@ -1468,14 +1591,16 @@ async fn successful_build_writes_a_full_build_record() { ); assert_eq!(None, record.output_dir); assert_eq!(Some("6.17.0"), record.kernelrelease.as_deref()); - assert!(record.log_path.starts_with(log_dir.to_str().unwrap())); + assert!(record + .log_path + .starts_with(log_dir.to_str().expect("path is utf-8"))); assert!(record.success); // The readiness latest-lookup parses built_at as RFC3339. assert!(DateTime::parse_from_rfc3339(&record.built_at).is_ok()); } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1488,9 +1613,12 @@ async fn failed_build_writes_a_failure_record() { env.expect_which().returning(|_| true); env }); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(2); let status = wait_for_terminal_status(&mut watch).await; assert!(matches!( @@ -1502,7 +1630,7 @@ async fn failed_build_writes_a_failure_record() { )); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); let record = &builds[0]; assert!(!record.success); @@ -1516,7 +1644,7 @@ async fn failed_build_writes_a_failure_record() { } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1529,17 +1657,20 @@ async fn cancelled_build_writes_no_record() { env.expect_which().returning(|_| true); env }); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); - handle.cancel().await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); + handle.cancel().await.expect("job cancels"); let status = wait_for_terminal_status(&mut watch).await; assert!(matches!(status, KwJobStatus::Cancelled { .. })); - assert!(builds.lock().unwrap().is_empty()); + assert!(builds.lock().expect("builds locks").is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1552,10 +1683,13 @@ async fn lost_exit_status_records_a_failed_build() { env.expect_which().returning(|_| true); env }); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); process.fail_waits(true); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let status = wait_for_terminal_status(&mut watch).await; @@ -1569,13 +1703,13 @@ async fn lost_exit_status_records_a_failed_build() { } )); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); assert!(!builds[0].success); } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1594,9 +1728,12 @@ async fn build_record_write_failure_keeps_the_terminal_status() { env.expect_which().returning(|_| true); env }); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let status = wait_for_terminal_status(&mut watch).await; @@ -1605,7 +1742,7 @@ async fn build_record_write_failure_keeps_the_terminal_status() { assert!(matches!(status, KwJobStatus::Succeeded { .. })); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1619,7 +1756,10 @@ async fn build_record_keeps_no_patchset_link_when_apply_lookup_fails() { ))) }); history.expect_record_build().returning(move |record| { - builds_in_store.lock().unwrap().push(record); + builds_in_store + .lock() + .expect("builds in store locks") + .push(record); Ok(()) }); let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); @@ -1629,22 +1769,25 @@ async fn build_record_keeps_no_patchset_link_when_apply_lookup_fails() { env.expect_which().returning(|_| true); env }); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let _ = wait_for_terminal_status(&mut watch).await; // The lookup error must not drop the record, only the link. { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); assert_eq!(None, builds[0].message_id); assert!(builds[0].success); } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -1699,15 +1842,18 @@ async fn successful_build_with_active_env_records_the_output_dir() { let (history, builds) = recording_history(None); let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); let (handle, process, log_dir) = spawn_full_actor("env-build", history, shell, fs, env); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_build(start_request()).await.unwrap(); + handle + .start_build(start_request()) + .await + .expect("build starts"); process.last_child().finish(0); let status = wait_for_terminal_status(&mut watch).await; assert!(matches!(status, KwJobStatus::Succeeded { .. })); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); let record = &builds[0]; let output_dir = record @@ -1732,5 +1878,5 @@ async fn successful_build_with_active_env_records_the_output_dir() { assert_eq!(1, env_current_reads.load(Ordering::SeqCst)); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } diff --git a/src/kw/actor/deploy_test.rs b/src/kw/actor/deploy_test.rs index 4fcf24a..575ce8f 100644 --- a/src/kw/actor/deploy_test.rs +++ b/src/kw/actor/deploy_test.rs @@ -22,7 +22,7 @@ async fn start_deploy_replies_immediately_and_runs_in_background() { ) .await .expect("start_deploy must reply immediately"); - result.unwrap(); + result.expect("deploy starts"); let spawned = process.spawned(); assert_eq!(1, spawned.len()); @@ -43,11 +43,11 @@ async fn start_deploy_replies_immediately_and_runs_in_background() { assert!(spawned[0] .log_path .file_name() - .unwrap() + .expect("path has a file name") .to_string_lossy() .starts_with("deploy-")); - let snapshot = handle.get_status().await.unwrap(); + let snapshot = handle.get_status().await.expect("status loads"); assert!( matches!( snapshot.job, @@ -62,7 +62,7 @@ async fn start_deploy_replies_immediately_and_runs_in_background() { ); process.last_child().finish(0); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); let status = wait_for_terminal_status(&mut watch).await; assert!( matches!( @@ -76,7 +76,7 @@ async fn start_deploy_replies_immediately_and_runs_in_background() { ); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -86,9 +86,12 @@ async fn deploy_exit_zero_with_initramfs_failure_succeeds_with_warnings() { deploy_history(Some(matching_build_record())), deploy_ready_fs(), ); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); - handle.start_deploy(deploy_request()).await.unwrap(); + handle + .start_deploy(deploy_request()) + .await + .expect("deploy starts"); process.last_child().write_log( b"update-initramfs: Generating /boot/initrd.img-6.17.0\n\ E: gzip compression (CONFIG_RD_GZIP) not supported by kernel\n\ @@ -114,19 +117,19 @@ async fn deploy_exit_zero_with_initramfs_failure_succeeds_with_warnings() { } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] async fn build_then_deploy_checks_grub_for_the_release_it_just_built() { let (handle, process, log_dir) = spawn_deploy_actor("chain-warnings", quiet_history(), deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); handle .start_build_then_deploy(deploy_request()) .await - .unwrap(); + .expect("build then deploy starts"); process.last_child().finish(0); wait_for_running_phase(&mut watch, KwPhase::Deploying).await; process.last_child().write_log( @@ -148,7 +151,7 @@ async fn build_then_deploy_checks_grub_for_the_release_it_just_built() { } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -176,7 +179,7 @@ async fn start_deploy_merges_extras_and_follows_reboot_force_options() { .into_iter() .map(String::from) .collect(); - handle.start_deploy(request).await.unwrap(); + handle.start_deploy(request).await.expect("deploy starts"); let spawned = process.spawned(); assert_eq!( @@ -186,7 +189,7 @@ async fn start_deploy_merges_extras_and_follows_reboot_force_options() { process.last_child().finish(0); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -200,11 +203,14 @@ async fn start_deploy_refused_without_a_build_record() { err, KwStartError::DeployAloneRefused(DeployAloneRefusal::NoBuildRecord) )); - assert_eq!(KwJobStatus::Idle, handle.get_status().await.unwrap().job); + assert_eq!( + KwJobStatus::Idle, + handle.get_status().await.expect("status loads").job + ); assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -226,7 +232,7 @@ async fn start_deploy_refused_when_last_build_failed() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -248,7 +254,7 @@ async fn start_deploy_refused_on_tree_path_drift() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -270,7 +276,7 @@ async fn start_deploy_refused_on_output_dir_mismatch() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -290,7 +296,7 @@ async fn start_deploy_refused_when_image_is_missing() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -310,7 +316,7 @@ async fn start_deploy_refused_when_remote_is_unresolved() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -327,7 +333,7 @@ async fn start_deploy_refused_when_boot_once_is_on_and_unacked() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -351,7 +357,7 @@ async fn start_deploy_refused_when_latest_build_is_on_another_branch() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -364,12 +370,12 @@ async fn start_deploy_proceeds_when_boot_once_is_on_and_acked() { let mut request = deploy_request(); request.deploy = Some(deploy_options(true)); - handle.start_deploy(request).await.unwrap(); + handle.start_deploy(request).await.expect("deploy starts"); assert_eq!(1, process.spawned().len()); process.last_child().finish(0); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -397,7 +403,7 @@ async fn start_deploy_post_switch_refusal_rolls_the_switch_back() { )); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -405,7 +411,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { let (history, builds) = recording_history(None); let (handle, process, log_dir) = spawn_deploy_actor("chain-success", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); let mut request = deploy_request(); request.extra_args = ["--verbose", "--ccache", "--alert=n"] @@ -418,7 +424,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { ) .await .expect("start_build_then_deploy must reply immediately") - .unwrap(); + .expect("build then deploy starts"); assert_eq!(1, process.spawned().len()); assert_eq!( @@ -427,7 +433,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { ); assert!( matches!( - handle.get_status().await.unwrap().job, + handle.get_status().await.expect("status loads").job, KwJobStatus::Running { kind: KwJobKind::BuildThenDeploy, phase: KwPhase::Building, @@ -435,7 +441,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { } ), "unexpected status: {:?}", - handle.get_status().await.unwrap().job + handle.get_status().await.expect("status loads").job ); let build = process.last_child(); @@ -452,7 +458,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { assert_eq!(KwPhase::Deploying, phase); assert!(log_path .file_name() - .unwrap() + .expect("path has a file name") .to_string_lossy() .starts_with("deploy-")); } @@ -475,7 +481,7 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { ); assert_eq!(Path::new("/home/user/linux"), spawned[1].cwd); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); assert!(builds[0].success); } @@ -492,10 +498,10 @@ async fn start_build_then_deploy_chains_deploy_after_a_successful_build() { ), "unexpected status: {status:?}" ); - assert_eq!(1, builds.lock().unwrap().len()); + assert_eq!(1, builds.lock().expect("builds locks").len()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -503,12 +509,12 @@ async fn start_build_then_deploy_skips_deploy_when_the_build_fails() { let (history, builds) = recording_history(None); let (handle, process, log_dir) = spawn_deploy_actor("chain-build-fail", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); handle .start_build_then_deploy(deploy_request()) .await - .unwrap(); + .expect("build then deploy starts"); process.last_child().finish(2); let status = wait_for_terminal_status(&mut watch).await; @@ -527,13 +533,13 @@ async fn start_build_then_deploy_skips_deploy_when_the_build_fails() { } assert_eq!(1, process.spawned().len()); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); assert!(!builds[0].success); } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -541,13 +547,13 @@ async fn start_build_then_deploy_cancel_in_building_skips_deploy_and_record() { let (history, builds) = recording_history(None); let (handle, process, log_dir) = spawn_deploy_actor("chain-cancel-build", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); handle .start_build_then_deploy(deploy_request()) .await - .unwrap(); - handle.cancel().await.unwrap(); + .expect("build then deploy starts"); + handle.cancel().await.expect("job cancels"); let status = wait_for_terminal_status(&mut watch).await; match status { @@ -559,10 +565,10 @@ async fn start_build_then_deploy_cancel_in_building_skips_deploy_and_record() { } assert_eq!(1, process.spawned().len()); assert!(process.last_child().was_killed()); - assert!(builds.lock().unwrap().is_empty()); + assert!(builds.lock().expect("builds locks").is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -570,14 +576,14 @@ async fn start_build_then_deploy_cancel_then_exit_zero_skips_deploy() { let (history, builds) = recording_history(None); let (handle, process, log_dir) = spawn_deploy_actor("chain-cancel-exit-zero", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); process.ignore_sigterm(true); handle .start_build_then_deploy(deploy_request()) .await - .unwrap(); - handle.cancel().await.unwrap(); + .expect("build then deploy starts"); + handle.cancel().await.expect("job cancels"); process.last_child().finish(0); let status = wait_for_terminal_status(&mut watch).await; @@ -589,10 +595,10 @@ async fn start_build_then_deploy_cancel_then_exit_zero_skips_deploy() { other => panic!("expected Cancelled Building, got {other:?}"), } assert_eq!(1, process.spawned().len()); - assert!(builds.lock().unwrap().is_empty()); + assert!(builds.lock().expect("builds locks").is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -600,17 +606,17 @@ async fn start_build_then_deploy_cancel_in_deploying_keeps_the_build_record() { let (history, builds) = recording_history(None); let (handle, process, log_dir) = spawn_deploy_actor("chain-cancel-deploy", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); handle .start_build_then_deploy(deploy_request()) .await - .unwrap(); + .expect("build then deploy starts"); process.last_child().finish(0); let _ = wait_for_running_phase(&mut watch, KwPhase::Deploying).await; - assert_eq!(1, builds.lock().unwrap().len()); + assert_eq!(1, builds.lock().expect("builds locks").len()); - handle.cancel().await.unwrap(); + handle.cancel().await.expect("job cancels"); let status = wait_for_terminal_status(&mut watch).await; match status { KwJobStatus::Cancelled { kind, phase, .. } => { @@ -622,13 +628,13 @@ async fn start_build_then_deploy_cancel_in_deploying_keeps_the_build_record() { assert_eq!(2, process.spawned().len()); assert!(process.last_child().was_killed()); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); assert!(builds[0].success); } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -636,12 +642,12 @@ async fn start_build_then_deploy_spawn_failure_at_boundary_keeps_the_build_recor let (history, builds) = recording_history(None); let (handle, process, log_dir) = spawn_deploy_actor("chain-spawn-fail", history, deploy_ready_fs()); - let mut watch = handle.watch_status().await.unwrap(); + let mut watch = handle.watch_status().await.expect("status watch opens"); handle .start_build_then_deploy(deploy_request()) .await - .unwrap(); + .expect("build then deploy starts"); let build = process.last_child(); process.refuse_spawns(true); build.finish(0); @@ -662,13 +668,13 @@ async fn start_build_then_deploy_spawn_failure_at_boundary_keeps_the_build_recor } assert_eq!(1, process.spawned().len()); { - let builds = builds.lock().unwrap(); + let builds = builds.lock().expect("builds locks"); assert_eq!(1, builds.len()); assert!(builds[0].success); } handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -691,5 +697,5 @@ async fn start_build_then_deploy_refused_when_remote_is_unresolved() { assert!(process.spawned().is_empty()); handle.shutdown().await; - fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } diff --git a/src/kw/history.rs b/src/kw/history.rs index 26ca978..236ff9e 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -196,12 +196,15 @@ mod tests { // A leftover from a failed previous run (pid reuse + counter reset) // must not poison this one. let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); dir } fn store_at(dir: &Path) -> FileKwHistoryStore { - FileKwHistoryStore::new(Arc::new(OsFileSystem), dir.to_str().unwrap().to_string()) + FileKwHistoryStore::new( + Arc::new(OsFileSystem), + dir.to_str().expect("path is utf-8").to_string(), + ) } fn record(message_id: &str, kernel_tree_id: &str, branch: &str) -> KwApplyRecord { @@ -250,25 +253,25 @@ mod tests { store .record_apply(record("msg-1", "mainline", "patchset-2026-08-01-17-30-00")) - .unwrap(); + .expect("apply records"); store .record_apply(record("msg-2", "mainline", "patchset-2026-08-02-10-00-00")) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record("msg-1", "mainline", "patchset-2026-08-01-17-30-00")), store .apply_record_for_branch("mainline", "patchset-2026-08-01-17-30-00") - .unwrap() + .expect("apply record loads") ); assert_eq!( Some(record("msg-2", "mainline", "patchset-2026-08-02-10-00-00")), store .apply_record_for_branch("mainline", "patchset-2026-08-02-10-00-00") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -278,25 +281,25 @@ mod tests { store .record_apply(record("msg-1", "mainline", "patchset-old")) - .unwrap(); + .expect("apply records"); store .record_apply(record("msg-1", "mainline", "patchset-new")) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record("msg-1", "mainline", "patchset-new")), store .apply_record_for_branch("mainline", "patchset-new") - .unwrap() + .expect("apply record loads") ); assert_eq!( None, store .apply_record_for_branch("mainline", "patchset-old") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -306,31 +309,31 @@ mod tests { store .record_apply(record("msg-1", "mainline", "patchset-mainline")) - .unwrap(); + .expect("apply records"); store .record_apply(record("msg-1", "stable", "patchset-stable")) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record("msg-1", "mainline", "patchset-mainline")), store .apply_record_for_branch("mainline", "patchset-mainline") - .unwrap() + .expect("apply record loads") ); assert_eq!( Some(record("msg-1", "stable", "patchset-stable")), store .apply_record_for_branch("stable", "patchset-stable") - .unwrap() + .expect("apply record loads") ); assert_eq!( None, store .apply_record_for_branch("amd-gfx", "patchset-mainline") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -342,10 +345,10 @@ mod tests { None, store .apply_record_for_branch("mainline", "patchset-x") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -355,25 +358,25 @@ mod tests { store .record_apply(record("msg-1", "mainline", "patchset-x")) - .unwrap(); + .expect("apply records"); store .record_apply(record("msg-2", "mainline", "patchset-y")) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record("msg-2", "mainline", "patchset-y")), store .apply_record_for_branch("mainline", "patchset-y") - .unwrap() + .expect("apply record loads") ); assert_eq!( None, store .apply_record_for_branch("mainline", "never-applied") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -384,25 +387,25 @@ mod tests { // The same branch name applied to two trees resolves per tree. store .record_apply(record("msg-1", "mainline", "patchset-x")) - .unwrap(); + .expect("apply records"); store .record_apply(record("msg-2", "stable", "patchset-x")) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record("msg-2", "stable", "patchset-x")), store .apply_record_for_branch("stable", "patchset-x") - .unwrap() + .expect("apply record loads") ); assert_eq!( None, store .apply_record_for_branch("amd-gfx", "patchset-x") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -420,7 +423,7 @@ mod tests { "patchset-x", "2026-08-01T10:00:00Z", )) - .unwrap(); + .expect("apply records"); store .record_apply(record_at( "msg-new", @@ -428,7 +431,7 @@ mod tests { "patchset-x", "2026-08-02T10:00:00Z", )) - .unwrap(); + .expect("apply records"); store .record_apply(record_at( "msg-broken", @@ -436,7 +439,7 @@ mod tests { "patchset-x", "not a timestamp", )) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record_at( @@ -447,10 +450,10 @@ mod tests { )), store .apply_record_for_branch("mainline", "patchset-x") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -462,10 +465,10 @@ mod tests { None, store .apply_record_for_branch("mainline", "patchset-x") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -476,29 +479,29 @@ mod tests { dir.join("nested") .join("deeper") .to_str() - .unwrap() + .expect("path is utf-8") .to_string(), ); store .record_apply(record("msg-1", "mainline", "patchset-x")) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record("msg-1", "mainline", "patchset-x")), store .apply_record_for_branch("mainline", "patchset-x") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] fn corrupt_history_file_errors_instead_of_clobbering() { let dir = tmp_dir("corrupt"); let store = store_at(&dir); - fs::write(dir.join(APPLY_HISTORY_FILENAME), b"not json").unwrap(); + fs::write(dir.join(APPLY_HISTORY_FILENAME), b"not json").expect("file writes"); let err = store .apply_record_for_branch("mainline", "patchset-x") @@ -511,10 +514,10 @@ mod tests { // The corrupt file is left untouched for the user to inspect. assert_eq!( "not json", - fs::read_to_string(dir.join(APPLY_HISTORY_FILENAME)).unwrap() + fs::read_to_string(dir.join(APPLY_HISTORY_FILENAME)).expect("file reads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -524,15 +527,15 @@ mod tests { store .record_apply(record("msg-1", "mainline", "patchset-x")) - .unwrap(); + .expect("apply records"); - let tmp_left = fs::read_dir(&dir).unwrap().any(|e| { + let tmp_left = fs::read_dir(&dir).expect("dir reads").any(|e| { e.ok() .is_some_and(|x| x.file_name().to_string_lossy().ends_with(".tmp")) }); assert!(!tmp_left, "atomic write should rename away .tmp"); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -542,12 +545,12 @@ mod tests { store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) - .unwrap(); + .expect("build records"); let mut failed = build("mainline", "patchset-x", "2026-08-02T09:00:00Z"); failed.success = false; failed.image_path = None; failed.kernelrelease = None; - store.record_build(failed.clone()).unwrap(); + store.record_build(failed.clone()).expect("build records"); // A failed attempt is stored, not dropped: deploy-alone readiness // refuses it. The newer failed record is also the tree's latest. @@ -556,18 +559,24 @@ mod tests { Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), Some(failed.clone()), ), - store.build_records("mainline", "for-next").unwrap() + store + .build_records("mainline", "for-next") + .expect("build history loads") ); assert_eq!( (Some(failed.clone()), Some(failed.clone())), - store.build_records("mainline", "patchset-x").unwrap() + store + .build_records("mainline", "patchset-x") + .expect("build history loads") ); assert_eq!( (None, Some(failed)), - store.build_records("mainline", "master").unwrap() + store + .build_records("mainline", "master") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -577,18 +586,20 @@ mod tests { store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) - .unwrap(); + .expect("build records"); store .record_build(build("mainline", "for-next", "2026-08-02T18:10:00Z")) - .unwrap(); + .expect("build records"); let overwritten = build("mainline", "for-next", "2026-08-02T18:10:00Z"); assert_eq!( (Some(overwritten.clone()), Some(overwritten)), - store.build_records("mainline", "for-next").unwrap() + store + .build_records("mainline", "for-next") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -598,13 +609,13 @@ mod tests { store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) - .unwrap(); + .expect("build records"); store .record_build(build("mainline", "patchset-x", "2026-08-02T18:10:00Z")) - .unwrap(); + .expect("build records"); store .record_build(build("stable", "for-next", "2026-08-03T18:10:00Z")) - .unwrap(); + .expect("build records"); let mainline_latest = build("mainline", "patchset-x", "2026-08-02T18:10:00Z"); assert_eq!( @@ -612,19 +623,25 @@ mod tests { Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), Some(mainline_latest.clone()), ), - store.build_records("mainline", "for-next").unwrap() + store + .build_records("mainline", "for-next") + .expect("build history loads") ); assert_eq!( (Some(mainline_latest.clone()), Some(mainline_latest)), - store.build_records("mainline", "patchset-x").unwrap() + store + .build_records("mainline", "patchset-x") + .expect("build history loads") ); let stable = build("stable", "for-next", "2026-08-03T18:10:00Z"); assert_eq!( (Some(stable.clone()), Some(stable)), - store.build_records("stable", "for-next").unwrap() + store + .build_records("stable", "for-next") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -634,17 +651,17 @@ mod tests { store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) - .unwrap(); + .expect("build records"); store .record_build(build("mainline", "patchset-x", "2026-08-03T18:10:00Z")) - .unwrap(); + .expect("build records"); store .record_build(build("mainline", "master", "2026-08-02T18:10:00Z")) - .unwrap(); + .expect("build records"); // Unparseable timestamps sort oldest. store .record_build(build("mainline", "broken-ts", "not a timestamp")) - .unwrap(); + .expect("build records"); let newest = build("mainline", "patchset-x", "2026-08-03T18:10:00Z"); assert_eq!( @@ -652,18 +669,24 @@ mod tests { Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), Some(newest.clone()), ), - store.build_records("mainline", "for-next").unwrap() + store + .build_records("mainline", "for-next") + .expect("build history loads") ); assert_eq!( (Some(newest.clone()), Some(newest)), - store.build_records("mainline", "patchset-x").unwrap() + store + .build_records("mainline", "patchset-x") + .expect("build history loads") ); assert_eq!( (None, None), - store.build_records("amd-gfx", "patchset-x").unwrap() + store + .build_records("amd-gfx", "patchset-x") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -673,17 +696,19 @@ mod tests { store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) - .unwrap(); + .expect("build records"); store .record_build(build("mainline", "patchset-x", "2026-08-03T18:10:00Z")) - .unwrap(); + .expect("build records"); assert_eq!( ( Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), Some(build("mainline", "patchset-x", "2026-08-03T18:10:00Z")), ), - store.build_records("mainline", "for-next").unwrap() + store + .build_records("mainline", "for-next") + .expect("build history loads") ); // An unbuilt branch still reports the tree's latest record. assert_eq!( @@ -691,14 +716,18 @@ mod tests { None, Some(build("mainline", "patchset-x", "2026-08-03T18:10:00Z")), ), - store.build_records("mainline", "never-built").unwrap() + store + .build_records("mainline", "never-built") + .expect("build history loads") ); assert_eq!( (None, None), - store.build_records("amd-gfx", "for-next").unwrap() + store + .build_records("amd-gfx", "for-next") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -708,17 +737,19 @@ mod tests { assert_eq!( (None, None), - store.build_records("mainline", "for-next").unwrap() + store + .build_records("mainline", "for-next") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] fn corrupt_build_history_errors_instead_of_clobbering() { let dir = tmp_dir("build-corrupt"); let store = store_at(&dir); - fs::write(dir.join(BUILD_HISTORY_FILENAME), b"not json").unwrap(); + fs::write(dir.join(BUILD_HISTORY_FILENAME), b"not json").expect("file writes"); let err = store.build_records("mainline", "for-next").unwrap_err(); assert!(err.to_string().contains(BUILD_HISTORY_FILENAME)); @@ -727,10 +758,10 @@ mod tests { .is_err()); assert_eq!( "not json", - fs::read_to_string(dir.join(BUILD_HISTORY_FILENAME)).unwrap() + fs::read_to_string(dir.join(BUILD_HISTORY_FILENAME)).expect("file reads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -740,15 +771,15 @@ mod tests { store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) - .unwrap(); + .expect("build records"); - let tmp_left = fs::read_dir(&dir).unwrap().any(|e| { + let tmp_left = fs::read_dir(&dir).expect("dir reads").any(|e| { e.ok() .is_some_and(|x| x.file_name().to_string_lossy().ends_with(".tmp")) }); assert!(!tmp_left, "atomic write should rename away .tmp"); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -758,25 +789,27 @@ mod tests { store .record_apply(record("msg-1", "mainline", "patchset-x")) - .unwrap(); + .expect("apply records"); assert!(!dir.join(BUILD_HISTORY_FILENAME).exists()); assert_eq!( (None, None), - store.build_records("mainline", "patchset-x").unwrap() + store + .build_records("mainline", "patchset-x") + .expect("build history loads") ); store .record_build(build("mainline", "patchset-x", "2026-08-01T18:10:00Z")) - .unwrap(); + .expect("build records"); assert!(dir.join(APPLY_HISTORY_FILENAME).exists()); assert!(dir.join(BUILD_HISTORY_FILENAME).exists()); assert_eq!( Some(record("msg-1", "mainline", "patchset-x")), store .apply_record_for_branch("mainline", "patchset-x") - .unwrap() + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } } diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index 6d665b0..9cfb5d2 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -253,7 +253,7 @@ make: *** [Makefile:248: __sub-make] Error 2 #[test] fn first_error_caps_long_lines() { let line = format!("drivers/x.c:1:1: error: {}", "y".repeat(400)); - let found = LogScanService::find_first_error(&line).unwrap(); + let found = LogScanService::find_first_error(&line).expect("first error finds"); assert_eq!(MAX_LINE_CHARS + 1, found.chars().count()); assert!(found.ends_with('…')); } diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 86c84ff..9a74cfb 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -544,7 +544,7 @@ mod tests { )); // A leftover from a failed previous run must not poison this one. let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); Self(dir) } @@ -583,10 +583,10 @@ mod tests { /// Creates the exact file/dir set kw's `is_kernel_root` expects. fn make_kernel_root(dir: &Path) { for file in KERNEL_ROOT_FILES { - fs::write(dir.join(file), "").unwrap(); + fs::write(dir.join(file), "").expect("file writes"); } for sub in KERNEL_ROOT_DIRS { - fs::create_dir(dir.join(sub)).unwrap(); + fs::create_dir(dir.join(sub)).expect("dir creates"); } } @@ -595,8 +595,8 @@ mod tests { fn make_ready_tree(test_name: &str) -> TempDir { let dir = TempDir::new(test_name); make_kernel_root(dir.path()); - fs::create_dir(dir.path().join(".kw")).unwrap(); - fs::write(dir.path().join(".config"), "").unwrap(); + fs::create_dir(dir.path().join(".kw")).expect("dir creates"); + fs::write(dir.path().join(".config"), "").expect("file writes"); dir } @@ -648,7 +648,7 @@ last_line_without_newline=yes"; make_kernel_root(dir.path()); fs::remove_dir_all(dir.path().join(member)) .or_else(|_| fs::remove_file(dir.path().join(member))) - .unwrap(); + .expect("kernel member removes"); assert!( !ReadinessService::is_kernel_root(&OsFileSystem, dir.path()), @@ -692,7 +692,7 @@ last_line_without_newline=yes"; fn probe_tree_reports_missing_kernel_config() { let dir = TempDir::new("probe-no-config"); make_kernel_root(dir.path()); - fs::create_dir(dir.path().join(".kw")).unwrap(); + fs::create_dir(dir.path().join(".kw")).expect("dir creates"); assert_eq!( TreeReadiness::MissingKernelConfig, @@ -713,7 +713,7 @@ last_line_without_newline=yes"; #[test] fn probe_tree_ready_reads_arch_from_build_config() { let dir = make_ready_tree("probe-ready-arch"); - fs::write(dir.path().join(".kw/build.config"), "arch=arm64\n").unwrap(); + fs::write(dir.path().join(".kw/build.config"), "arch=arm64\n").expect("file writes"); assert_eq!( TreeReadiness::Ready { @@ -728,14 +728,14 @@ last_line_without_newline=yes"; let dir = make_ready_tree("probe-env"); let out = TempDir::new("probe-env-output"); // With a kw env active, kw moves the .config into the env's O= dir. - fs::remove_file(dir.path().join(".config")).unwrap(); + fs::remove_file(dir.path().join(".config")).expect("file removes"); assert_eq!( TreeReadiness::MissingKernelConfig, ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())) ); - fs::write(out.path().join(".config"), "").unwrap(); + fs::write(out.path().join(".config"), "").expect("file writes"); assert!(matches!( ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())), TreeReadiness::Ready { .. } @@ -746,7 +746,7 @@ last_line_without_newline=yes"; fn probe_tree_with_active_env_refuses_in_tree_config() { let dir = make_ready_tree("probe-env-in-tree-config"); let out = TempDir::new("probe-env-in-tree-config-output"); - fs::write(out.path().join(".config"), "").unwrap(); + fs::write(out.path().join(".config"), "").expect("file writes"); assert_eq!( TreeReadiness::InTreeBuildArtifacts, @@ -757,10 +757,10 @@ last_line_without_newline=yes"; #[test] fn probe_tree_with_active_env_refuses_in_tree_include_config() { let dir = make_ready_tree("probe-env-in-tree-include-config"); - fs::remove_file(dir.path().join(".config")).unwrap(); - fs::create_dir(dir.path().join("include/config")).unwrap(); + fs::remove_file(dir.path().join(".config")).expect("file removes"); + fs::create_dir(dir.path().join("include/config")).expect("dir creates"); let out = TempDir::new("probe-env-in-tree-include-config-output"); - fs::write(out.path().join(".config"), "").unwrap(); + fs::write(out.path().join(".config"), "").expect("file writes"); assert_eq!( TreeReadiness::InTreeBuildArtifacts, @@ -771,7 +771,7 @@ last_line_without_newline=yes"; #[test] fn probe_tree_without_env_accepts_in_tree_build_artifacts() { let dir = make_ready_tree("probe-no-env-in-tree-artifacts"); - fs::create_dir(dir.path().join("include/config")).unwrap(); + fs::create_dir(dir.path().join("include/config")).expect("dir creates"); assert_eq!( TreeReadiness::Ready { arch: None }, @@ -782,7 +782,7 @@ last_line_without_newline=yes"; #[test] fn read_build_arch_reads_literal_value() { let dir = make_ready_tree("arch-literal"); - fs::write(dir.path().join(".kw/build.config"), "arch=x86\n").unwrap(); + fs::write(dir.path().join(".kw/build.config"), "arch=x86\n").expect("file writes"); assert_eq!( Some("x86".to_string()), @@ -805,21 +805,21 @@ last_line_without_newline=yes"; no_key.path().join(".kw/build.config"), "cpu_scaling_factor=100\n", ) - .unwrap(); + .expect("file writes"); assert_eq!( None, ReadinessService::read_build_arch(&OsFileSystem, no_key.path()) ); let commented = make_ready_tree("arch-commented"); - fs::write(commented.path().join(".kw/build.config"), "#arch=riscv\n").unwrap(); + fs::write(commented.path().join(".kw/build.config"), "#arch=riscv\n").expect("file writes"); assert_eq!( None, ReadinessService::read_build_arch(&OsFileSystem, commented.path()) ); let empty = make_ready_tree("arch-empty"); - fs::write(empty.path().join(".kw/build.config"), "arch=\n").unwrap(); + fs::write(empty.path().join(".kw/build.config"), "arch=\n").expect("file writes"); assert_eq!( None, ReadinessService::read_build_arch(&OsFileSystem, empty.path()) @@ -829,12 +829,12 @@ last_line_without_newline=yes"; /// Creates a file whose mtime is `mtime_secs` seconds after the epoch, /// so newest-wins ordering is fully deterministic. fn write_file_with_mtime(path: &Path, mtime_secs: u64) { - let file = fs::File::create(path).unwrap(); + let file = fs::File::create(path).expect("file creates"); file.set_times( fs::FileTimes::new() .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs)), ) - .unwrap(); + .expect("mtime sets"); } #[test] @@ -844,7 +844,8 @@ last_line_without_newline=yes"; assert_eq!( None, - ReadinessService::resolve_output_dir(&OsFileSystem, &env, dir.path()).unwrap() + ReadinessService::resolve_output_dir(&OsFileSystem, &env, dir.path()) + .expect("output dir resolves") ); } @@ -862,7 +863,8 @@ last_line_without_newline=yes"; .returning(|_| Ok("/xdg".to_string())); let resolved = - ReadinessService::resolve_output_dir(&fs, &env, Path::new("/home/user/linux")).unwrap(); + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/home/user/linux")) + .expect("output dir resolves"); assert_eq!( Some(PathBuf::from("/xdg/kw/envs/L2hvbWUvdXNlci9saW51eA==/minix")), @@ -884,8 +886,8 @@ last_line_without_newline=yes"; .withf(|key| key == "HOME") .returning(|_| Ok("/home/user".to_string())); - let resolved = - ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap(); + let resolved = ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")) + .expect("output dir resolves"); assert_eq!( Some( @@ -913,8 +915,8 @@ last_line_without_newline=yes"; .withf(|key| key == "HOME") .returning(|_| Ok("/home/user".to_string())); - let resolved = - ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap(); + let resolved = ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")) + .expect("output dir resolves"); assert_eq!( Some( @@ -941,7 +943,7 @@ last_line_without_newline=yes"; let resolved = ReadinessService::resolve_output_dir(&fs, &env, Path::new("/home/user/linux/")) - .unwrap(); + .expect("output dir resolves"); assert_eq!( Some(PathBuf::from("/xdg/kw/envs/L2hvbWUvdXNlci9saW51eA==/minix")), @@ -959,7 +961,8 @@ last_line_without_newline=yes"; assert_eq!( None, - ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap() + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")) + .expect("output dir resolves") ); } @@ -991,7 +994,7 @@ last_line_without_newline=yes"; fn find_image_with_arch_picks_newest_image_only() { let dir = make_ready_tree("image-arch"); let boot = dir.path().join("arch/x86/boot"); - fs::create_dir_all(&boot).unwrap(); + fs::create_dir_all(&boot).expect("dir creates"); write_file_with_mtime(&boot.join("bzImage"), 100); write_file_with_mtime(&boot.join("Image"), 200); // Neither name matches find's case-sensitive `*Image`. @@ -1011,8 +1014,8 @@ last_line_without_newline=yes"; let dir = make_ready_tree("image-glob"); let x86_boot = dir.path().join("arch/x86/boot"); let arm64_boot = dir.path().join("arch/arm64/boot"); - fs::create_dir_all(&x86_boot).unwrap(); - fs::create_dir_all(&arm64_boot).unwrap(); + fs::create_dir_all(&x86_boot).expect("dir creates"); + fs::create_dir_all(&arm64_boot).expect("dir creates"); write_file_with_mtime(&x86_boot.join("bzImage"), 100); write_file_with_mtime(&arm64_boot.join("Image"), 200); @@ -1028,7 +1031,7 @@ last_line_without_newline=yes"; // lexicographically larger path. let dir = make_ready_tree("image-tie"); let boot = dir.path().join("arch/x86/boot"); - fs::create_dir_all(&boot).unwrap(); + fs::create_dir_all(&boot).expect("dir creates"); write_file_with_mtime(&boot.join("bzImage"), 100); write_file_with_mtime(&boot.join("zImage"), 100); @@ -1052,7 +1055,7 @@ last_line_without_newline=yes"; ); let boot = dir.path().join("arch/x86/boot"); - fs::create_dir_all(&boot).unwrap(); + fs::create_dir_all(&boot).expect("dir creates"); write_file_with_mtime(&boot.join("image"), 100); // lowercase: no match write_file_with_mtime(&boot.join("Image.gz"), 200); // suffix: no match @@ -1070,8 +1073,8 @@ last_line_without_newline=yes"; fn kernelrelease_reads_and_trims_the_release_file() { let dir = make_ready_tree("kernelrelease"); let config_dir = dir.path().join("include").join("config"); - fs::create_dir_all(&config_dir).unwrap(); - fs::write(config_dir.join("kernel.release"), "6.17.0-rc1\n").unwrap(); + fs::create_dir_all(&config_dir).expect("dir creates"); + fs::write(config_dir.join("kernel.release"), "6.17.0-rc1\n").expect("file writes"); assert_eq!( Some("6.17.0-rc1".to_string()), @@ -1089,8 +1092,8 @@ last_line_without_newline=yes"; ); let config_dir = dir.path().join("include").join("config"); - fs::create_dir_all(&config_dir).unwrap(); - fs::write(config_dir.join("kernel.release"), "\n").unwrap(); + fs::create_dir_all(&config_dir).expect("dir creates"); + fs::write(config_dir.join("kernel.release"), "\n").expect("file writes"); assert_eq!( None, ReadinessService::read_kernelrelease(&OsFileSystem, dir.path()) @@ -1107,16 +1110,16 @@ last_line_without_newline=yes"; fn kernel_tree(path: &Path) -> KernelTree { serde_json::from_value(serde_json::json!({ - "path": path.to_str().unwrap(), + "path": path.to_str().expect("path is utf-8"), "branch": "master" })) - .unwrap() + .expect("json parses") } fn built_record(tree: &Path, branch: &str) -> KwBuildRecord { KwBuildRecord { kernel_tree_id: "mainline".to_string(), - tree_path: tree.to_str().unwrap().to_string(), + tree_path: tree.to_str().expect("path is utf-8").to_string(), message_id: None, branch: branch.to_string(), arch: Some("x86".to_string()), @@ -1166,7 +1169,12 @@ last_line_without_newline=yes"; shell .expect_execute() .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) - .returning(move |_| Ok(shell_output(str::from_utf8(&stdout_bytes).unwrap(), true))); + .returning(move |_| { + Ok(shell_output( + str::from_utf8(&stdout_bytes).expect("bytes are utf-8"), + true, + )) + }); let probe = ReadinessService::probe_kw_binary(&env, &shell); @@ -1300,7 +1308,7 @@ last_line_without_newline=yes"; let moved_tree = kernel_tree(Path::new("/elsewhere/linux")); assert_eq!( Err(DeployAloneRefusal::TreePathDrift { - recorded: dir.path().to_str().unwrap().to_string(), + recorded: dir.path().to_str().expect("path is utf-8").to_string(), current: "/elsewhere/linux".to_string(), }), ReadinessService::check_deploy_alone( @@ -1353,7 +1361,7 @@ last_line_without_newline=yes"; // A trailing-slash-only difference is the same tree, not drift. let mut slashed = built_record(dir.path(), "patchset-x"); - slashed.tree_path = format!("{}/", dir.path().to_str().unwrap()); + slashed.tree_path = format!("{}/", dir.path().to_str().expect("path is utf-8")); assert_eq!( Ok(()), ReadinessService::check_deploy_alone( @@ -1383,29 +1391,29 @@ last_line_without_newline=yes"; #[test] fn evaluate_readiness_composes_all_probes() { let dir = make_ready_tree("evaluate"); - fs::write(dir.path().join(".kw/build.config"), "arch=x86\n").unwrap(); + fs::write(dir.path().join(".kw/build.config"), "arch=x86\n").expect("file writes"); fs::write( dir.path().join(".kw/deploy.config"), "boot_into_new_kernel_once=no\n", ) - .unwrap(); + .expect("file writes"); fs::write( dir.path().join(".kw/remote.config"), "#kw-default=dut\nHost dut\n Hostname box\n Port 22\n User root\n", ) - .unwrap(); + .expect("file writes"); let boot = dir.path().join("arch/x86/boot"); - fs::create_dir_all(&boot).unwrap(); + fs::create_dir_all(&boot).expect("dir creates"); write_file_with_mtime(&boot.join("bzImage"), 100); let data = TempDir::new("evaluate-data"); let history = FileKwHistoryStore::new( Arc::new(OsFileSystem), - data.path().to_str().unwrap().to_string(), + data.path().to_str().expect("path is utf-8").to_string(), ); history .record_build(built_record(dir.path(), "patchset-x")) - .unwrap(); + .expect("build records"); let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| true); @@ -1425,7 +1433,7 @@ last_line_without_newline=yes"; "patchset-x", None, ) - .unwrap(); + .expect("readiness evaluates"); assert_eq!( TreeReadiness::Ready { @@ -1440,7 +1448,13 @@ last_line_without_newline=yes"; assert!(readiness.kw_binary.available); assert_eq!(KwVersionCheck::Meets, readiness.kw_binary.check); assert_eq!(Some("patchset-x".to_string()), readiness.current_branch); - assert_eq!("root@box:22", readiness.deploy_remote.unwrap().endpoint()); + assert_eq!( + "root@box:22", + readiness + .deploy_remote + .expect("deploy remote is set") + .endpoint() + ); assert_eq!(BootOnceState::Off, readiness.boot_once); } @@ -1451,7 +1465,7 @@ last_line_without_newline=yes"; let data = TempDir::new("evaluate-missing-data"); let history = FileKwHistoryStore::new( Arc::new(OsFileSystem), - data.path().to_str().unwrap().to_string(), + data.path().to_str().expect("path is utf-8").to_string(), ); let mut env = MockEnvTrait::new(); @@ -1472,7 +1486,7 @@ last_line_without_newline=yes"; "patchset-x", None, ) - .unwrap(); + .expect("readiness evaluates"); assert_eq!(TreeReadiness::Missing, readiness.tree); // Tree readiness is conjoined into the deploy-alone verdict, so @@ -1494,7 +1508,7 @@ last_line_without_newline=yes"; let data = TempDir::new("evaluate-nobuild-data"); let history = FileKwHistoryStore::new( Arc::new(OsFileSystem), - data.path().to_str().unwrap().to_string(), + data.path().to_str().expect("path is utf-8").to_string(), ); let mut env = MockEnvTrait::new(); @@ -1515,7 +1529,7 @@ last_line_without_newline=yes"; "patchset-x", None, ) - .unwrap(); + .expect("readiness evaluates"); // No build.config: arch stays None (glob fallback); no images exist. assert_eq!(TreeReadiness::Ready { arch: None }, readiness.tree); @@ -1534,7 +1548,7 @@ last_line_without_newline=yes"; let data = TempDir::new("evaluate-detached-data"); let history = FileKwHistoryStore::new( Arc::new(OsFileSystem), - data.path().to_str().unwrap().to_string(), + data.path().to_str().expect("path is utf-8").to_string(), ); let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| false); @@ -1553,7 +1567,7 @@ last_line_without_newline=yes"; "", None, ) - .unwrap(); + .expect("readiness evaluates"); assert_eq!(None, readiness.current_branch); } @@ -1563,15 +1577,15 @@ last_line_without_newline=yes"; let data = TempDir::new("evaluate-for-branch-data"); let history = FileKwHistoryStore::new( Arc::new(OsFileSystem), - data.path().to_str().unwrap().to_string(), + data.path().to_str().expect("path is utf-8").to_string(), ); history .record_build(built_record(dir.path(), "patchset-x")) - .unwrap(); + .expect("build records"); let boot = dir.path().join("arch/x86/boot"); - fs::create_dir_all(&boot).unwrap(); + fs::create_dir_all(&boot).expect("dir creates"); write_file_with_mtime(&boot.join("bzImage"), 100); - fs::write(dir.path().join(".kw/build.config"), "arch=x86\n").unwrap(); + fs::write(dir.path().join(".kw/build.config"), "arch=x86\n").expect("file writes"); let mut env = MockEnvTrait::new(); env.expect_which().returning(|_| false); @@ -1591,7 +1605,7 @@ last_line_without_newline=yes"; "master", None, ) - .unwrap(); + .expect("readiness evaluates"); assert_eq!(Some("master".to_string()), on_head.current_branch); assert_eq!( Err(DeployAloneRefusal::HeadMismatch { @@ -1611,7 +1625,7 @@ last_line_without_newline=yes"; "master", Some("patchset-x"), ) - .unwrap(); + .expect("readiness evaluates"); // HEAD is still master; deploy-alone is judged against the typed branch. assert_eq!(Some("master".to_string()), for_typed.current_branch); assert_eq!(Ok(()), for_typed.deploy_alone); @@ -1624,7 +1638,7 @@ last_line_without_newline=yes"; off.path().join(".kw/deploy.config"), "boot_into_new_kernel_once=no\n", ) - .unwrap(); + .expect("file writes"); let env = MockEnvTrait::new(); assert_eq!( BootOnceState::Off, @@ -1636,7 +1650,7 @@ last_line_without_newline=yes"; on.path().join(".kw/deploy.config"), "boot_into_new_kernel_once=yes\n", ) - .unwrap(); + .expect("file writes"); assert_eq!( BootOnceState::On, ReadinessService::probe_boot_once(&OsFileSystem, &env, on.path()) @@ -1659,7 +1673,7 @@ last_line_without_newline=yes"; weird.path().join(".kw/deploy.config"), "boot_into_new_kernel_once=true\n", ) - .unwrap(); + .expect("file writes"); assert_eq!( BootOnceState::Unknown, ReadinessService::probe_boot_once(&OsFileSystem, &MockEnvTrait::new(), weird.path()) diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 8395098..2039752 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -273,13 +273,15 @@ Host ph-dut ); assert_eq!( "abc@arch-tm:22", - choose(SAMPLE_REMOTE_CONFIG).unwrap().endpoint() + choose(SAMPLE_REMOTE_CONFIG) + .expect("candidate resolves") + .endpoint() ); } #[test] fn lab_fixture_ignores_identity_file() { - let chosen = choose(LAB_FIXTURE).unwrap(); + let chosen = choose(LAB_FIXTURE).expect("candidate resolves"); assert_eq!( remote("ph-dut", "lima-ph-dut.internal", 22, Some("root")), chosen @@ -294,7 +296,7 @@ Host dut Hostname 192.0.2.10 User root "; - let chosen = choose(content).unwrap(); + let chosen = choose(content).expect("candidate resolves"); assert_eq!(remote("dut", "192.0.2.10", 22, Some("root")), chosen); assert_eq!("root@192.0.2.10:22", chosen.endpoint()); } @@ -305,7 +307,7 @@ Host dut Host dut Hostname box "; - let chosen = choose(content).unwrap(); + let chosen = choose(content).expect("candidate resolves"); assert_eq!(22, chosen.port); assert_eq!(None, chosen.user); assert_eq!("box:22", chosen.endpoint()); @@ -362,7 +364,7 @@ Host other Port 22 User other "; - let chosen = choose(content).unwrap(); + let chosen = choose(content).expect("candidate resolves"); assert_eq!(remote("origin", "192.0.2.1", 2222, Some("root")), chosen); } @@ -376,7 +378,7 @@ Host dut "; assert_eq!( remote("dut", "Box", 2222, Some("Root")), - choose(content).unwrap() + choose(content).expect("candidate resolves") ); } @@ -442,7 +444,7 @@ Host ok let content = "Host dut\n Hostname box\n User root"; assert_eq!( remote("dut", "box", 22, Some("root")), - choose(content).unwrap() + choose(content).expect("candidate resolves") ); } @@ -453,7 +455,7 @@ Host ok Host dut extra Hostname box "; - assert_eq!("dut", choose(content).unwrap().name); + assert_eq!("dut", choose(content).expect("candidate resolves").name); } #[test] @@ -515,7 +517,7 @@ Host dut extra let chosen = RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/home/user/linux")) - .unwrap(); + .expect("deploy remote resolves"); assert_eq!("ph-dut", chosen.name); } @@ -529,7 +531,7 @@ Host dut extra let chosen = RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/home/user/linux")) - .unwrap(); + .expect("deploy remote resolves"); assert_eq!("root@box:22", chosen.endpoint()); } @@ -541,8 +543,8 @@ Host dut extra )]); let env = env_with(Some(""), Some("/home/user")); - let chosen = - RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")).unwrap(); + let chosen = RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")) + .expect("deploy remote resolves"); assert_eq!("box:22", chosen.endpoint()); } diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index b5cc6d8..3ebecd4 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -317,7 +317,10 @@ mod tests { let handle = spawn_test_actor(make_service(lists_store, user_state)); - let data = handle.get_bootstrap_data().await.unwrap(); + let data = handle + .get_bootstrap_data() + .await + .expect("bootstrap data loads"); assert_eq!(1, data.mailing_lists.len()); assert_eq!("linux-mm", data.mailing_lists[0].name()); @@ -338,11 +341,11 @@ mod tests { let first = handle .fetch_available_lists(CacheMode::UseCache) .await - .unwrap(); + .expect("available lists fetches"); let second = handle .fetch_available_lists(CacheMode::UseCache) .await - .unwrap(); + .expect("available lists fetches"); assert_eq!("cached-list", first[0].name()); assert_eq!("cached-list", second[0].name()); diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index c0a23bf..d1e0d26 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -530,7 +530,9 @@ mod tests { MockPatchsetParser::new(), ); - let result = svc.fetch_available_lists(CacheMode::UseCache).unwrap(); + let result = svc + .fetch_available_lists(CacheMode::UseCache) + .expect("available lists fetches"); assert_eq!(1, result.len()); assert_eq!("linux-mm", result[0].name()); } @@ -554,7 +556,9 @@ mod tests { "in memory", )])); - let result = svc.fetch_available_lists(CacheMode::UseCache).unwrap(); + let result = svc + .fetch_available_lists(CacheMode::UseCache) + .expect("available lists fetches"); assert_eq!(1, result.len()); assert_eq!("cached-list", result[0].name()); } @@ -570,7 +574,7 @@ mod tests { Ok(fs::read_to_string( "test_samples/lore_session/process_available_lists/available_lists_response-1.html", ) - .unwrap()) + .expect("file reads")) }); lists_gateway .expect_fetch_available_lists_page() @@ -580,7 +584,7 @@ mod tests { Ok(fs::read_to_string( "test_samples/lore_session/process_available_lists/available_lists_response-2.html", ) - .unwrap()) + .expect("file reads")) }); lists_gateway .expect_fetch_available_lists_page() @@ -590,7 +594,7 @@ mod tests { Ok(fs::read_to_string( "test_samples/lore_session/process_available_lists/available_lists_response-3.html", ) - .unwrap()) + .expect("file reads")) }); let mut lists_store = MockMailingListsCacheStore::new(); @@ -609,7 +613,9 @@ mod tests { MockPatchsetParser::new(), ); - let lists = svc.fetch_available_lists(CacheMode::Refresh).unwrap(); + let lists = svc + .fetch_available_lists(CacheMode::Refresh) + .expect("available lists fetches"); assert_eq!(320, lists.len()); assert_eq!("accel-config", lists[0].name()); assert_eq!("yocto-toaster", lists[319].name()); @@ -646,7 +652,9 @@ mod tests { "", )])); - let result = svc.fetch_available_lists(CacheMode::Refresh).unwrap(); + let result = svc + .fetch_available_lists(CacheMode::Refresh) + .expect("available lists fetches"); // Network returned an empty page, so result is empty. assert!(result.is_empty()); // In-memory cache was updated (cleared then set to empty result). @@ -681,7 +689,7 @@ mod tests { MockPatchsetParser::new(), ); - let data = svc.warm_bootstrap_cache().unwrap(); + let data = svc.warm_bootstrap_cache().expect("bootstrap cache warms"); assert_eq!(1, data.mailing_lists.len()); assert_eq!("linux-mm", data.mailing_lists[0].name()); assert!(data.bookmarks.is_empty()); @@ -700,7 +708,7 @@ mod tests { .expect_fetch_patch_feed_page() .withf(move |list, offset| list == target && *offset == 0) .times(1) - .returning(move |_, _| Ok(fs::read_to_string(src).unwrap())); + .returning(move |_, _| Ok(fs::read_to_string(src).expect("file reads"))); let mut svc = make_service( MockListsGateway::new(), @@ -714,7 +722,7 @@ mod tests { let patches = svc .fetch_next_patch_page(target, 1, 1, CacheMode::UseCache) - .unwrap(); + .expect("next patch page fetches"); assert_eq!(1, patches.len()); assert!(patches[0] .message_id() @@ -761,8 +769,8 @@ mod tests { ); let feed = { - let xml = fs::read_to_string(src).unwrap(); - LoreParserService::parse_patch_feed(&xml).unwrap() + let xml = fs::read_to_string(src).expect("file reads"); + LoreParserService::parse_patch_feed(&xml).expect("patch feed parses") }; let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); @@ -772,7 +780,7 @@ mod tests { let patches = svc .fetch_next_patch_page(target, 1, 1, CacheMode::UseCache) - .unwrap(); + .expect("next patch page fetches"); assert_eq!(1, patches.len()); } @@ -786,7 +794,7 @@ mod tests { feed_gateway .expect_fetch_patch_feed_page() .times(1) - .returning(move |_, _| Ok(fs::read_to_string(src).unwrap())); + .returning(move |_, _| Ok(fs::read_to_string(src).expect("file reads"))); let mut svc = make_service( MockListsGateway::new(), @@ -800,8 +808,8 @@ mod tests { // Pre-populate the cache. let feed = { - let xml = fs::read_to_string(src).unwrap(); - LoreParserService::parse_patch_feed(&xml).unwrap() + let xml = fs::read_to_string(src).expect("file reads"); + LoreParserService::parse_patch_feed(&xml).expect("patch feed parses") }; let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); @@ -811,7 +819,7 @@ mod tests { let patches = svc .fetch_next_patch_page(target, 1, 1, CacheMode::Refresh) - .unwrap(); + .expect("next patch page fetches"); assert_eq!(1, patches.len()); } @@ -824,7 +832,7 @@ mod tests { "link": { "@href": msg_id }, "updated": "2023-01-01" })) - .unwrap() + .expect("json parses") } #[test] @@ -854,7 +862,7 @@ mod tests { let details = svc .fetch_patchset_details(&patch, CacheMode::UseCache) - .unwrap(); + .expect("patchset details fetches"); assert_eq!("/tmp/cached.mbx", details.patchset_path); assert_eq!(1, details.raw_patches.len()); } @@ -888,7 +896,7 @@ mod tests { let details = svc .fetch_patchset_details(&patch, CacheMode::UseCache) - .unwrap(); + .expect("patchset details fetches"); assert_eq!("/tmp/new.mbx", details.patchset_path); // The result should now be in cache. let key = PatchsetCacheKey::from(&patch); @@ -931,7 +939,7 @@ mod tests { let details = svc .fetch_patchset_details(&patch, CacheMode::Refresh) - .unwrap(); + .expect("patchset details fetches"); assert_eq!("/tmp/refreshed.mbx", details.patchset_path); // Cache is updated with the fresh entry. assert_eq!( diff --git a/src/lore/domain/mailing_list.rs b/src/lore/domain/mailing_list.rs index 571b9bc..75eabea 100644 --- a/src/lore/domain/mailing_list.rs +++ b/src/lore/domain/mailing_list.rs @@ -39,7 +39,7 @@ mod tests { let expected_mailing_list = MailingList::new("list-name", "List Description"); let serialized_mailing_list = r#"{"name":"list-name","description":"List Description"}"#; let deserialized_mailing_list: MailingList = - serde_json::from_str(serialized_mailing_list).unwrap(); + serde_json::from_str(serialized_mailing_list).expect("config parses"); assert_eq!( expected_mailing_list, deserialized_mailing_list, @@ -52,7 +52,8 @@ mod tests { let expected_serialized_mailing_list = r#"{"name":"list-name","description":"List Description"}"#; let mailing_list = MailingList::new("list-name", "List Description"); - let serialized_mailing_list = serde_json::to_string(&mailing_list).unwrap(); + let serialized_mailing_list = + serde_json::to_string(&mailing_list).expect("config serializes"); assert_eq!( expected_serialized_mailing_list, serialized_mailing_list, diff --git a/src/lore/domain/patch.rs b/src/lore/domain/patch.rs index 9767b86..9fd8b89 100644 --- a/src/lore/domain/patch.rs +++ b/src/lore/domain/patch.rs @@ -195,7 +195,7 @@ mod tests { "#; - let actual_patch: Patch = from_str(serialized_patch).unwrap(); + let actual_patch: Patch = from_str(serialized_patch).expect("patch parses"); assert_eq!( expected_patch, actual_patch, @@ -248,7 +248,7 @@ mod tests { "#; - let actual_patch: Patch = from_str(serialized_patch).unwrap(); + let actual_patch: Patch = from_str(serialized_patch).expect("patch parses"); assert_eq!( expected_patch, actual_patch, diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index ee39cf0..afcb7a5 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -120,8 +120,8 @@ mod tests { use crate::lore::infrastructure::parsers::LoreParserService; fn feed_from_file(path: &str) -> PatchFeed { - let xml = fs::read_to_string(path).unwrap(); - LoreParserService::parse_patch_feed(&xml).unwrap() + let xml = fs::read_to_string(path).expect("file reads"); + LoreParserService::parse_patch_feed(&xml).expect("patch feed parses") } #[test] @@ -143,7 +143,7 @@ mod tests { assert_eq!(1, idx.representative_patch_ids().len()); let id = &idx.representative_patch_ids()[0]; assert!(id.contains("1234.567-1-john@johnson.com")); - let patch = idx.patches_by_id.get(id).unwrap(); + let patch = idx.patches_by_id.get(id).expect("entry is present"); assert_eq!("some/subsystem: Do this and that", patch.title()); assert_eq!(1, patch.version()); } @@ -196,10 +196,10 @@ mod tests { ); idx.process_feed_page(feed); - let page = idx.get_page(2, 1).unwrap(); + let page = idx.get_page(2, 1).expect("page loads"); assert_eq!(2, page.len()); - let page2 = idx.get_page(2, 2).unwrap(); + let page2 = idx.get_page(2, 2).expect("page loads"); assert_eq!(1, page2.len()); assert!(idx.get_page(2, 3).is_none()); diff --git a/src/lore/infrastructure/parsers.rs b/src/lore/infrastructure/parsers.rs index d6ff6a3..88d4e3d 100644 --- a/src/lore/infrastructure/parsers.rs +++ b/src/lore/infrastructure/parsers.rs @@ -70,7 +70,7 @@ mod tests { let html = fs::read_to_string( "test_samples/lore_session/process_available_lists/available_lists_response-1.html", ) - .unwrap(); + .expect("file reads"); let lists = LoreParserService::parse_available_lists(&html); @@ -89,11 +89,11 @@ mod tests { let xml = fs::read_to_string( "test_samples/lore_session/process_representative_patch/patch_feed_sample_1.xml", ) - .unwrap(); + .expect("file reads"); let feed = LoreParserService::parse_patch_feed(&xml); assert!(feed.is_ok(), "Should parse a valid feed XML"); - assert!(!feed.unwrap().patches().is_empty()); + assert!(!feed.expect("feed loads").patches().is_empty()); } #[test] diff --git a/src/lore/infrastructure/patchset_fetcher.rs b/src/lore/infrastructure/patchset_fetcher.rs index ff78567..b8326bd 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -132,7 +132,10 @@ mod tests { "#, ); - LoreParserService::parse_patch_feed(&xml).unwrap().patches()[0].clone() + LoreParserService::parse_patch_feed(&xml) + .expect("patch feed parses") + .patches()[0] + .clone() } #[test] diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index 2f87857..e305d97 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -257,17 +257,17 @@ mod tests { assert_eq!(3, patches.len(), "Wrong number of patches"); assert_eq!( fs::read_to_string("test_samples/lore_session/split_patchset/expected_patch_1.mbx") - .unwrap(), + .expect("file reads"), patches[0] ); assert_eq!( fs::read_to_string("test_samples/lore_session/split_patchset/expected_patch_2.mbx") - .unwrap(), + .expect("file reads"), patches[1] ); assert_eq!( fs::read_to_string("test_samples/lore_session/split_patchset/expected_patch_3.mbx") - .unwrap(), + .expect("file reads"), patches[2] ); } @@ -283,23 +283,23 @@ mod tests { fs::read_to_string( "test_samples/lore_session/split_patchset/expected_cover_letter.cover" ) - .unwrap(), + .expect("file reads"), patches[0], "Wrong cover letter" ); assert_eq!( fs::read_to_string("test_samples/lore_session/split_patchset/expected_patch_1.mbx") - .unwrap(), + .expect("file reads"), patches[1] ); assert_eq!( fs::read_to_string("test_samples/lore_session/split_patchset/expected_patch_2.mbx") - .unwrap(), + .expect("file reads"), patches[2] ); assert_eq!( fs::read_to_string("test_samples/lore_session/split_patchset/expected_patch_3.mbx") - .unwrap(), + .expect("file reads"), patches[3] ); } @@ -325,11 +325,11 @@ mod tests { let patch_sample = fs::read_to_string( "test_samples/lore_session/generate_patch_reply_template/patch_sample.mbx", ) - .unwrap(); + .expect("file reads"); let expected = fs::read_to_string( "test_samples/lore_session/generate_patch_reply_template/expected_reply_template.mbx", ) - .unwrap(); + .expect("file reads"); assert_eq!( expected, @@ -342,7 +342,7 @@ mod tests { let patch_html = fs::read_to_string( "test_samples/lore_session/extract_git_reply_command/patch_lore_sample.html", ) - .unwrap(); + .expect("file reads"); let reply_path = "/tmp/some-reply.mbx"; let expected = ShellCommand { program: "git".to_string(), diff --git a/src/lore/infrastructure/persistence.rs b/src/lore/infrastructure/persistence.rs index 7f752ef..24bda54 100644 --- a/src/lore/infrastructure/persistence.rs +++ b/src/lore/infrastructure/persistence.rs @@ -121,16 +121,25 @@ mod tests { test_name, process::id() )); - fs::create_dir_all(&dir).unwrap(); + fs::create_dir_all(&dir).expect("dir creates"); dir } fn make_persistence(dir: &Path) -> FileLorePersistence { FileLorePersistence::new( Arc::new(OsFileSystem), - dir.join("lists.json").to_str().unwrap().to_string(), - dir.join("bookmarked.json").to_str().unwrap().to_string(), - dir.join("reviewed.json").to_str().unwrap().to_string(), + dir.join("lists.json") + .to_str() + .expect("path is utf-8") + .to_string(), + dir.join("bookmarked.json") + .to_str() + .expect("path is utf-8") + .to_string(), + dir.join("reviewed.json") + .to_str() + .expect("path is utf-8") + .to_string(), ) } @@ -145,14 +154,15 @@ mod tests { ]; // MailingListsCacheStore - p.save_available_lists(&lists).unwrap(); - let loaded = p.load_available_lists().unwrap(); + p.save_available_lists(&lists) + .expect("available lists saves"); + let loaded = p.load_available_lists().expect("available lists loads"); assert_eq!(2, loaded.len()); assert_eq!("linux-mm", loaded[0].name()); assert_eq!("linux-kernel", loaded[1].name()); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -165,14 +175,17 @@ mod tests { reviewed.entry("some-id".to_string()).or_default().insert(2); // UserLoreStateStore - p.save_reviewed_patchsets(&reviewed).unwrap(); - let loaded = p.load_reviewed_patchsets().unwrap(); + p.save_reviewed_patchsets(&reviewed) + .expect("reviewed patchsets saves"); + let loaded = p + .load_reviewed_patchsets() + .expect("reviewed patchsets loads"); assert!(loaded.contains_key("some-id")); assert!(loaded["some-id"].contains(&1)); assert!(loaded["some-id"].contains(&2)); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -185,6 +198,6 @@ mod tests { assert!(p.load_bookmarked_patchsets().is_err()); assert!(p.load_reviewed_patchsets().is_err()); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } } diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index ed1b44e..da9e13f 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -214,8 +214,11 @@ mod tests { session.expect_size().times(1).returning(|| Ok((120, 40))); let handle = spawn_test_actor(session); - handle.draw(TerminalFrame::Empty).await.unwrap(); - let size = handle.size().await.unwrap(); + handle + .draw(TerminalFrame::Empty) + .await + .expect("terminal draws"); + let size = handle.size().await.expect("terminal size reads"); assert_eq!(size, (120, 40)); } @@ -233,7 +236,7 @@ mod tests { let pressed = handle .wait_for_key_press(KeyCode::Enter, Duration::from_millis(50)) .await - .unwrap(); + .expect("key press completes"); assert!(pressed); } @@ -244,7 +247,7 @@ mod tests { session.expect_setup_user_io().times(1).returning(|| Ok(())); let handle = spawn_test_actor(session); - handle.setup_user_io().await.unwrap(); + handle.setup_user_io().await.expect("user io sets up"); } #[tokio::test] @@ -256,7 +259,7 @@ mod tests { .returning(|| Ok(())); let handle = spawn_test_actor(session); - handle.teardown_user_io().await.unwrap(); + handle.teardown_user_io().await.expect("user io tears down"); } #[tokio::test] @@ -265,8 +268,8 @@ mod tests { session.expect_shutdown().times(2).returning(|| Ok(())); let handle = spawn_test_actor(session); - handle.shutdown().await.unwrap(); - handle.shutdown().await.unwrap(); + handle.shutdown().await.expect("actor shuts down"); + handle.shutdown().await.expect("actor shuts down"); } #[tokio::test] diff --git a/src/ui/core.rs b/src/ui/core.rs index 1a87cfa..fcb8086 100644 --- a/src/ui/core.rs +++ b/src/ui/core.rs @@ -135,7 +135,7 @@ mod tests { fn nav_text(vm: AppViewModel) -> String { UiCore::new() .build_scene(&vm) - .unwrap() + .expect("scene builds") .navigation .mode_spans .into_iter() From abe8f95d0c78a297cdfa71dff17a5223786589ab Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 14:26:47 -0300 Subject: [PATCH 53/61] test(kw): assert call counts and arguments on mock expectations Kw mock expectations now say how often they run and which arguments they accept. Counts come from a run of the kw tests. Shared fixtures use a measured range, because callers probe them a different number of times: the recording shell, the ready-tree filesystem, and the remote env helper. .withf(|_| true) appears 7 times, each with .times(0). The call never happens, so the argument is not observed: - record_build on deploy-alone history, in deploy_history and in the deploy test that must not write a build record. - execute when the kw binary is missing, and on the four evaluate_readiness paths that return before any shell command. history.rs tests use the real store and set no mock expectations. Signed-off-by: lorenzoberts --- src/kw/actor/actor_test.rs | 786 ++++++++++++++++++++++++++---------- src/kw/actor/deploy_test.rs | 4 +- src/kw/readiness.rs | 199 +++++++-- src/kw/remote.rs | 53 ++- 4 files changed, 782 insertions(+), 260 deletions(-) diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index 36677eb..d4695ae 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -119,27 +119,55 @@ fn recording_shell( let calls = Arc::new(Mutex::new(Vec::new())); let calls_in_shell = Arc::clone(&calls); let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(move |cmd| { - calls_in_shell - .lock() - .expect("calls in shell locks") - .push(command_parts(cmd)); - let output = |stdout: &[u8], stderr: &[u8], success: bool| ShellOutput { - stdout: stdout.to_vec(), - stderr: stderr.to_vec(), - success, - }; - if cmd.program == "kw" { - return Ok(output(kw_version, b"", true)); - } - if cmd.args.iter().any(|arg| arg == "status") { - return Ok(output(status.0, status.1, status.2)); - } - if cmd.args.iter().any(|arg| arg == "switch") { - return Ok(output(b"", switch.0, switch.1)); - } - Ok(output(b"master\n", b"", true)) - }); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/home/user/linux", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/home/user/linux", + "switch", + "--", + "patchset-2026-08-01-17-30-00", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "switch", "--", "master"] + }) + .times(2..=9) + .returning(move |cmd| { + calls_in_shell + .lock() + .expect("calls in shell locks") + .push(command_parts(cmd)); + let output = |stdout: &[u8], stderr: &[u8], success: bool| ShellOutput { + stdout: stdout.to_vec(), + stderr: stderr.to_vec(), + success, + }; + if cmd.program == "kw" { + return Ok(output(kw_version, b"", true)); + } + if cmd.args.iter().any(|arg| arg == "status") { + return Ok(output(status.0, status.1, status.2)); + } + if cmd.args.iter().any(|arg| arg == "switch") { + return Ok(output(b"", switch.0, switch.1)); + } + Ok(output(b"master\n", b"", true)) + }); (shell, calls) } @@ -172,43 +200,74 @@ impl GitStub { let dirty = Arc::clone(&self.dirty); let fail_switch_to = Arc::clone(&self.fail_switch_to); let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(move |cmd| { - let output = |stdout: &[u8]| ShellOutput { - stdout: stdout.to_vec(), - stderr: Vec::new(), - success: true, - }; - if cmd.program == "kw" { - return Ok(output(KW_VERSION_OK)); - } - if cmd.args.iter().any(|arg| arg == "status") { - let stdout: &[u8] = if dirty.load(Ordering::Relaxed) { - b" M src/main.c\n" - } else { - b"" + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/home/user/linux", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/home/user/linux", + "switch", + "--", + "patchset-2026-08-01-17-30-00", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "switch", "--", "master"] + || cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "switch", "--", "patchset-two"] + }) + .times(5..=11) + .returning(move |cmd| { + let output = |stdout: &[u8]| ShellOutput { + stdout: stdout.to_vec(), + stderr: Vec::new(), + success: true, }; - return Ok(output(stdout)); - } - if cmd.args.iter().any(|arg| arg == "switch") { - let branch = cmd.args.last().expect("iterator yields last").clone(); - if fail_switch_to - .lock() - .expect("fail switch to locks") - .as_deref() - == Some(branch.as_str()) - { - return Ok(ShellOutput { - stdout: Vec::new(), - stderr: b"error: you need to resolve your current index first\n".to_vec(), - success: false, - }); + if cmd.program == "kw" { + return Ok(output(KW_VERSION_OK)); } - *head.lock().expect("head locks") = branch; - return Ok(output(b"")); - } - let current = format!("{}\n", head.lock().expect("head locks")); - Ok(output(current.as_bytes())) - }); + if cmd.args.iter().any(|arg| arg == "status") { + let stdout: &[u8] = if dirty.load(Ordering::Relaxed) { + b" M src/main.c\n" + } else { + b"" + }; + return Ok(output(stdout)); + } + if cmd.args.iter().any(|arg| arg == "switch") { + let branch = cmd.args.last().expect("iterator yields last").clone(); + if fail_switch_to + .lock() + .expect("fail switch to locks") + .as_deref() + == Some(branch.as_str()) + { + return Ok(ShellOutput { + stdout: Vec::new(), + stderr: b"error: you need to resolve your current index first\n" + .to_vec(), + success: false, + }); + } + *head.lock().expect("head locks") = branch; + return Ok(output(b"")); + } + let current = format!("{}\n", head.lock().expect("head locks")); + Ok(output(current.as_bytes())) + }); shell } } @@ -228,24 +287,63 @@ impl GitStub { /// absent, `.kw/build.config` is unreadable (arch probes as None), /// and there is no arch/ dir to glob images from. fn expect_ready_tree(fs: &mut MockFileSystemTrait) { - fs.expect_is_dir().returning(|_| true); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(12..=24) + .returning(|_| true); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + }) + .times(7..=14) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(|path| { - read_real_job_log(path).unwrap_or_else(|| { + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1..=2) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/build.config") + || path == std::path::Path::new("/home/user/linux/include/config/kernel.release") + || path.extension() == Some("log".as_ref()) + }) + .times(1..=3) + .returning(|path| { + read_real_job_log(path).unwrap_or_else(|| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }) + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch")) + .times(0..=1) + .returning(|_| { Err(FileSystemError::IoError(io::Error::new( io::ErrorKind::NotFound, "missing", ))) - }) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); + }); } /// Job logs are real files ([`FakeProcess`] creates them and @@ -260,7 +358,10 @@ fn read_real_job_log(path: &Path) -> Option> { fn ready_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); expect_ready_tree(&mut fs); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1..=2) + .returning(|_| Ok(())); fs } @@ -271,37 +372,82 @@ fn ready_fs() -> MockFileSystemTrait { /// epoch — still the only, hence newest, candidate. fn built_tree_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(12) + .returning(|_| true); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + || path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage") + }) + .times(7..=8) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(|path| { - if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_read_dir().returning(|path| { - if path.ends_with("arch/x86/boot") { - Ok(vec![PathBuf::from( - "/home/user/linux/arch/x86/boot/bzImage", - )]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/build.config") + || path == std::path::Path::new("/home/user/linux/include/config/kernel.release") + || path.extension() == Some("log".as_ref()) + }) + .times(2) + .returning(|path| { + if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot")) + .times(0..=1) + .returning(|path| { + if path.ends_with("arch/x86/boot") { + Ok(vec![PathBuf::from( + "/home/user/linux/arch/x86/boot/bzImage", + )]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); fs.expect_metadata() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage")) + .times(0..=1) .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); fs } @@ -320,43 +466,92 @@ pub(super) fn deploy_fs( has_image: bool, ) -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(12) + .returning(|_| true); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.config") + || path == std::path::Path::new("/home/user/linux/.kw/deploy.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + || path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage") + }) + .times(8..=10) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(move |path| { - if let Some(log) = read_real_job_log(path) { - log - } else if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else if path.ends_with("remote.config") { - Ok(remote_config.to_string()) - } else if path.ends_with("deploy.config") { - Ok(deploy_config.to_string()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_read_dir().returning(move |path| { - if has_image && path.ends_with("arch/x86/boot") { - Ok(vec![PathBuf::from( - "/home/user/linux/arch/x86/boot/bzImage", - )]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/build.config") + || path == std::path::Path::new("/home/user/linux/.kw/deploy.config") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path == std::path::Path::new("/home/user/linux/include/config/kernel.release") + || path.extension() == Some("log".as_ref()) + }) + .times(2..=5) + .returning(move |path| { + if let Some(log) = read_real_job_log(path) { + log + } else if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else if path.ends_with("remote.config") { + Ok(remote_config.to_string()) + } else if path.ends_with("deploy.config") { + Ok(deploy_config.to_string()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot")) + .times(0..=1) + .returning(move |path| { + if has_image && path.ends_with("arch/x86/boot") { + Ok(vec![PathBuf::from( + "/home/user/linux/arch/x86/boot/bzImage", + )]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); fs.expect_metadata() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage")) + .times(0..=1) .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(0..=2) + .returning(|_| Ok(())); fs } @@ -399,14 +594,18 @@ pub(super) fn deploy_history(record: Option) -> MockKwHistoryStor .withf(|kernel_tree_id, branch| { kernel_tree_id == "mainline" && branch == "patchset-2026-08-01-17-30-00" }) + .times(1) .returning(move |_, _| Ok((record.clone(), record.clone()))); - history.expect_record_build().times(0); + history.expect_record_build().withf(|_| true).times(0); history } pub(super) fn env_with_kw() -> MockEnvTrait { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env } @@ -425,8 +624,14 @@ pub(super) fn quiet_history() -> MockKwHistoryStore { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") + .times(1) .returning(|_, _| Ok(None)); - history.expect_record_build().returning(|_| Ok(())); + history + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(|_| Ok(())); history } @@ -440,14 +645,20 @@ pub(super) fn recording_history( let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") + .times(1) .returning(move |_, _| Ok(apply_record.clone())); - history.expect_record_build().returning(move |record| { - builds_in_store - .lock() - .expect("builds in store locks") - .push(record); - Ok(()) - }); + history + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(move |record| { + builds_in_store + .lock() + .expect("builds in store locks") + .push(record); + Ok(()) + }); (history, builds) } @@ -483,7 +694,10 @@ fn spawn_job_actor_with_mocks( fs: MockFileSystemTrait, ) -> (KwHandle, Arc, PathBuf) { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1..=2) + .returning(|_| true); spawn_full_actor(test_name, quiet_history(), shell, fs, env) } @@ -532,6 +746,14 @@ async fn record_apply_surfaces_store_errors() { let mut history = MockKwHistoryStore::new(); history .expect_record_apply() + .withf(|record| { + record.message_id == "msg-1" + && record.kernel_tree_id == "mainline" + && record.tree_path == "/home/user/linux" + && record.applied_branch == "patchset-2026-08-01-17-30-00" + && record.base_branch == "master" + && record.applied_at == "2026-08-01T17:30:00Z" + }) .times(1) .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); let handle = spawn_test_actor( @@ -588,18 +810,34 @@ async fn get_readiness_composes_probes_and_head_branch() { let mut env = MockEnvTrait::new(); env.expect_which() .withf(|name| name == "kw") + .times(1) .returning(|_| false); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(4) .returning(|_| Err(env::VarError::NotPresent.into())); let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| false); - fs.expect_is_dir().returning(|_| false); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/deploy.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + }) + .times(3) + .returning(|_| false); + fs.expect_is_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux")) + .times(1) + .returning(|_| false); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch")) + .times(1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); let mut history = MockKwHistoryStore::new(); history .expect_build_records() @@ -650,18 +888,34 @@ async fn get_readiness_for_branch_looks_up_that_branch_not_head() { let mut env = MockEnvTrait::new(); env.expect_which() .withf(|name| name == "kw") + .times(1) .returning(|_| false); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(4) .returning(|_| Err(env::VarError::NotPresent.into())); let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| false); - fs.expect_is_dir().returning(|_| false); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/deploy.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + }) + .times(3) + .returning(|_| false); + fs.expect_is_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux")) + .times(1) + .returning(|_| false); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch")) + .times(1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); let mut history = MockKwHistoryStore::new(); history .expect_build_records() @@ -952,7 +1206,10 @@ async fn spawn_failure_refuses_start_and_stays_idle() { async fn start_build_refused_when_kw_binary_missing() { let process = Arc::new(FakeProcess::new()); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| false); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| false); let log_dir = tmp_log_dir("kw-missing"); let handle = KwActor::spawn( Arc::new(MockKwHistoryStore::new()), @@ -982,23 +1239,57 @@ async fn start_build_refused_when_kw_binary_missing() { async fn start_build_refused_when_tree_not_ready() { let process = Arc::new(FakeProcess::new()); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(|_| { - Ok(ShellOutput { - stdout: b"kw, version 0.10.0\n".to_vec(), - stderr: Vec::new(), - success: true, - }) - }); + shell + .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) + .returning(|_| { + Ok(ShellOutput { + stdout: b"kw, version 0.10.0\n".to_vec(), + stderr: Vec::new(), + success: true, + }) + }); // The kernel-root probes pass, but there is no .kw directory: kw // init was never run in this tree. let mut fs = MockFileSystemTrait::new(); fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(12) .returning(|path| path.file_name().is_none_or(|name| name != ".kw")); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + }) + .times(6) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1) + .returning(|_| true); let log_dir = tmp_log_dir("tree-not-ready"); let handle = KwActor::spawn( Arc::new(MockKwHistoryStore::new()), @@ -1417,11 +1708,14 @@ async fn log_dir_creation_failure_refuses_start_and_stays_idle() { let git = GitStub::on_branch("master"); let mut fs = MockFileSystemTrait::new(); expect_ready_tree(&mut fs); - fs.expect_create_dir_all().returning(|_| { - Err(FileSystemError::IoError(io::Error::other( - "read-only filesystem", - ))) - }); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::other( + "read-only filesystem", + ))) + }); let (handle, process, log_dir) = spawn_job_actor_with_mocks("log-dir-fail", git.shell(), fs); let err = handle.start_build(start_request()).await.unwrap_err(); @@ -1563,7 +1857,10 @@ async fn successful_build_writes_a_full_build_record() { let (handle, process, log_dir) = spawn_full_actor("build-record", history, shell, built_tree_fs(), { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env }); let mut watch = handle.watch_status().await.expect("status watch opens"); @@ -1610,7 +1907,10 @@ async fn failed_build_writes_a_failure_record() { let (handle, process, log_dir) = spawn_full_actor("failed-record", history, shell, built_tree_fs(), { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env }); let mut watch = handle.watch_status().await.expect("status watch opens"); @@ -1654,7 +1954,10 @@ async fn cancelled_build_writes_no_record() { let (handle, _process, log_dir) = spawn_full_actor("cancel-record", history, shell, ready_fs(), { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env }); let mut watch = handle.watch_status().await.expect("status watch opens"); @@ -1680,7 +1983,10 @@ async fn lost_exit_status_records_a_failed_build() { let (handle, process, log_dir) = spawn_full_actor("wait-failure", history, shell, ready_fs(), { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env }); let mut watch = handle.watch_status().await.expect("status watch opens"); @@ -1717,15 +2023,22 @@ async fn build_record_write_failure_keeps_the_terminal_status() { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") + .times(1) .returning(|_, _| Ok(None)); history .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") + .times(1) .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); let (handle, process, log_dir) = spawn_full_actor("record-write-fails", history, shell, ready_fs(), { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env }); let mut watch = handle.watch_status().await.expect("status watch opens"); @@ -1750,23 +2063,34 @@ async fn build_record_keeps_no_patchset_link_when_apply_lookup_fails() { let builds = Arc::new(Mutex::new(Vec::new())); let builds_in_store = Arc::clone(&builds); let mut history = MockKwHistoryStore::new(); - history.expect_apply_record_for_branch().returning(|_, _| { - Err(FileSystemError::IoError(io::Error::other( - "corrupt history", - ))) - }); - history.expect_record_build().returning(move |record| { - builds_in_store - .lock() - .expect("builds in store locks") - .push(record); - Ok(()) - }); + history + .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(|_, _| { + Err(FileSystemError::IoError(io::Error::other( + "corrupt history", + ))) + }); + history + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(move |record| { + builds_in_store + .lock() + .expect("builds in store locks") + .push(record); + Ok(()) + }); let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); let (handle, process, log_dir) = spawn_full_actor("link-lookup-fails", history, shell, ready_fs(), { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env }); let mut watch = handle.watch_status().await.expect("status watch opens"); @@ -1801,11 +2125,46 @@ async fn successful_build_with_active_env_records_the_output_dir() { // With an env active the build artifacts live only under O=; the // source tree is clean. fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/include/config") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(13) .returning(|path| path != Path::new("/home/user/linux/include/config")); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new( + "/home/user/.cache/kw/envs/L2hvbWUvdXNlci9saW51eA==/testenv/.config", + ) || path == std::path::Path::new( + "/home/user/.cache/kw/envs/L2hvbWUvdXNlci9saW51eA==/testenv/arch/x86/boot/bzImage", + ) || path == std::path::Path::new("/home/user/linux/.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + }) + .times(9) .returning(|path| path != Path::new("/home/user/linux/.config")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(move |path| { + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string().withf(|path| path == std::path::Path::new("/home/user/.cache/kw/envs/L2hvbWUvdXNlci9saW51eA==/testenv/include/config/kernel.release") + || path == std::path::Path::new("/home/user/linux/.kw/build.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current")).times(3).returning(move |path| { if path.ends_with("env.current") { env_current_reads_in_fs.fetch_add(1, Ordering::SeqCst); Ok("testenv\n".to_string()) @@ -1822,22 +2181,43 @@ async fn successful_build_with_active_env_records_the_output_dir() { }); // Every boot-dir probe answers with an image inside the probed // dir: the record's image path shows which build root was used. - fs.expect_read_dir().returning(|path| { - if path.ends_with("arch/x86/boot") { - Ok(vec![path.join("bzImage")]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); + fs.expect_read_dir() + .withf(|path| { + path == std::path::Path::new( + "/home/user/.cache/kw/envs/L2hvbWUvdXNlci9saW51eA==/testenv/arch/x86/boot", + ) + }) + .times(1) + .returning(|path| { + if path.ends_with("arch/x86/boot") { + Ok(vec![path.join("bzImage")]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); fs.expect_metadata() + .withf(|path| { + path == std::path::Path::new( + "/home/user/.cache/kw/envs/L2hvbWUvdXNlci9saW51eA==/testenv/arch/x86/boot/bzImage", + ) + }) + .times(1) .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env.expect_var() + .withf(|key| key == "XDG_CACHE_HOME") + .times(1) .returning(|_| Ok("/home/user/.cache".to_string())); let (history, builds) = recording_history(None); let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); diff --git a/src/kw/actor/deploy_test.rs b/src/kw/actor/deploy_test.rs index 575ce8f..aef40b0 100644 --- a/src/kw/actor/deploy_test.rs +++ b/src/kw/actor/deploy_test.rs @@ -343,8 +343,10 @@ async fn start_deploy_refused_when_latest_build_is_on_another_branch() { let mut history = MockKwHistoryStore::new(); history .expect_build_records() + .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") + .times(1) .returning(move |_, _| Ok((None, Some(latest.clone())))); - history.expect_record_build().times(0); + history.expect_record_build().withf(|_| true).times(0); let (handle, process, log_dir) = spawn_deploy_actor("deploy-head-mismatch", history, deploy_ready_fs()); diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 9a74cfb..aed40ee 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -854,12 +854,17 @@ last_line_without_newline=yes"; // Known vector: `printf '%s' /home/user/linux | base64 --wrap=0`. let mut fs = MockFileSystemTrait::new(); fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/home/user/linux/.kw/env.current")) + .times(1) .returning(|p| p == Path::new("/home/user/linux/.kw/env.current")); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/home/user/linux/.kw/env.current")) + .times(1) .returning(|_| Ok("minix\n".to_string())); let mut env = MockEnvTrait::new(); env.expect_var() .withf(|key| key == "XDG_CACHE_HOME") + .times(1) .returning(|_| Ok("/xdg".to_string())); let resolved = @@ -875,15 +880,22 @@ last_line_without_newline=yes"; #[test] fn resolve_output_dir_falls_back_to_home_cache() { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| true); + fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) + .returning(|_| true); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) .returning(|_| Ok("minix\n".to_string())); let mut env = MockEnvTrait::new(); env.expect_var() .withf(|key| key == "XDG_CACHE_HOME") + .times(1) .returning(|_| Err(env::VarError::NotPresent.into())); env.expect_var() .withf(|key| key == "HOME") + .times(1) .returning(|_| Ok("/home/user".to_string())); let resolved = ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")) @@ -904,15 +916,22 @@ last_line_without_newline=yes"; // bash's `:-` (and the XDG spec) treat set-but-empty as unset; // otherwise the resolved path would be relative to cwd. let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| true); + fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) + .returning(|_| true); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) .returning(|_| Ok("minix\n".to_string())); let mut env = MockEnvTrait::new(); env.expect_var() .withf(|key| key == "XDG_CACHE_HOME") + .times(1) .returning(|_| Ok(String::new())); env.expect_var() .withf(|key| key == "HOME") + .times(1) .returning(|_| Ok("/home/user".to_string())); let resolved = ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")) @@ -933,12 +952,18 @@ last_line_without_newline=yes"; // kw encodes $PWD after cd-ing into the tree, where the path no // longer carries a trailing slash. let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| true); + fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/home/user/linux/.kw/env.current")) + .times(1) + .returning(|_| true); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/home/user/linux/.kw/env.current")) + .times(1) .returning(|_| Ok("minix\n".to_string())); let mut env = MockEnvTrait::new(); env.expect_var() .withf(|key| key == "XDG_CACHE_HOME") + .times(1) .returning(|_| Ok("/xdg".to_string())); let resolved = @@ -954,8 +979,13 @@ last_line_without_newline=yes"; #[test] fn resolve_output_dir_empty_env_file_is_inactive() { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| true); + fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) + .returning(|_| true); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) .returning(|_| Ok("\n".to_string())); let env = MockEnvTrait::new(); @@ -969,8 +999,13 @@ last_line_without_newline=yes"; #[test] fn resolve_output_dir_unreadable_env_file_errors() { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| true); + fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) + .returning(|_| true); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) .returning(|_| Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied").into())); let env = MockEnvTrait::new(); @@ -980,11 +1015,18 @@ last_line_without_newline=yes"; #[test] fn resolve_output_dir_errors_without_any_cache_base() { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|_| true); + fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) + .returning(|_| true); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/kernel/.kw/env.current")) + .times(1) .returning(|_| Ok("minix\n".to_string())); let mut env = MockEnvTrait::new(); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CACHE_HOME")) + .times(2) .returning(|_| Err(env::VarError::NotPresent.into())); assert!(ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); @@ -1137,9 +1179,10 @@ last_line_without_newline=yes"; let mut env = MockEnvTrait::new(); env.expect_which() .withf(|name| name == "kw") + .times(1) .returning(|_| false); let mut shell = MockShellTrait::new(); - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let probe = ReadinessService::probe_kw_binary(&env, &shell); @@ -1163,12 +1206,16 @@ last_line_without_newline=yes"; ("not a version\n", KwVersionCheck::Unknown), ] { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); let mut shell = MockShellTrait::new(); let stdout_bytes = stdout.as_bytes().to_vec(); shell .expect_execute() .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) .returning(move |_| { Ok(shell_output( str::from_utf8(&stdout_bytes).expect("bytes are utf-8"), @@ -1186,14 +1233,21 @@ last_line_without_newline=yes"; #[test] fn kw_probe_keeps_the_raw_first_line_verbatim() { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(|_| { - Ok(shell_output( - "beta-0.9\nBranch: master\nCommit: 3575d38\n", - true, - )) - }); + shell + .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) + .returning(|_| { + Ok(shell_output( + "beta-0.9\nBranch: master\nCommit: 3575d38\n", + true, + )) + }); let probe = ReadinessService::probe_kw_binary(&env, &shell); @@ -1206,21 +1260,30 @@ last_line_without_newline=yes"; let mut spawn_fails = MockShellTrait::new(); spawn_fails .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) .returning(|_| Err(io::Error::other("spawn failed").into())); let mut empty_stdout = MockShellTrait::new(); empty_stdout .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) .returning(|_| Ok(shell_output("", true))); let mut kw_fails = MockShellTrait::new(); kw_fails .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) .returning(|_| Ok(shell_output("0.10.0\n", false))); for shell in [spawn_fails, empty_stdout, kw_fails] { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); let probe = ReadinessService::probe_kw_binary(&env, &shell); @@ -1416,10 +1479,15 @@ last_line_without_newline=yes"; .expect("build records"); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); let mut shell = MockShellTrait::new(); shell .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) .returning(|_| Ok(shell_output("0.10.0\n", true))); let tree = kernel_tree(dir.path()); @@ -1469,11 +1537,16 @@ last_line_without_newline=yes"; ); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| false); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| false); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(4) .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let tree = kernel_tree(&missing); let readiness = ReadinessService::evaluate_readiness( @@ -1512,11 +1585,16 @@ last_line_without_newline=yes"; ); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| false); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| false); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(4) .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let tree = kernel_tree(dir.path()); let readiness = ReadinessService::evaluate_readiness( @@ -1551,11 +1629,16 @@ last_line_without_newline=yes"; data.path().to_str().expect("path is utf-8").to_string(), ); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| false); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| false); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(4) .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let tree = kernel_tree(dir.path()); let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, @@ -1588,11 +1671,16 @@ last_line_without_newline=yes"; fs::write(dir.path().join(".kw/build.config"), "arch=x86\n").expect("file writes"); let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| false); + env.expect_which() + .withf(|name| name == "kw") + .times(2) + .returning(|_| false); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(8) .returning(|_| Err(env::VarError::NotPresent.into())); let mut shell = MockShellTrait::new(); - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let tree = kernel_tree(dir.path()); let on_head = ReadinessService::evaluate_readiness( @@ -1662,6 +1750,8 @@ last_line_without_newline=yes"; let empty = make_ready_tree("boot-once-missing"); let mut env = MockEnvTrait::new(); env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(2) .returning(|_| Err(env::VarError::NotPresent.into())); assert_eq!( BootOnceState::Unknown, @@ -1683,19 +1773,32 @@ last_line_without_newline=yes"; #[test] fn probe_boot_once_falls_through_to_xdg_when_the_tree_omits_the_key() { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file().returning(|path| { - path.ends_with(".kw/deploy.config") || path == Path::new("/xdg/kw/deploy.config") - }); - fs.expect_read_to_string().returning(|path| { - if path.ends_with(".kw/deploy.config") { - Ok("reboot=no\n".to_string()) - } else { - Ok("boot_into_new_kernel_once=no\n".to_string()) - } - }); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/deploy.config") + || path == std::path::Path::new("/xdg/kw/deploy.config") + }) + .times(2) + .returning(|path| { + path.ends_with(".kw/deploy.config") || path == Path::new("/xdg/kw/deploy.config") + }); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/deploy.config") + || path == std::path::Path::new("/xdg/kw/deploy.config") + }) + .times(2) + .returning(|path| { + if path.ends_with(".kw/deploy.config") { + Ok("reboot=no\n".to_string()) + } else { + Ok("boot_into_new_kernel_once=no\n".to_string()) + } + }); let mut env = MockEnvTrait::new(); env.expect_var() .withf(|key| key == "XDG_CONFIG_HOME") + .times(1) .returning(|_| Ok("/xdg".to_string())); assert_eq!( @@ -1708,13 +1811,18 @@ last_line_without_newline=yes"; fn probe_boot_once_unreadable_tree_file_does_not_fall_through() { let mut fs = MockFileSystemTrait::new(); fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/kernel/.kw/deploy.config")) + .times(1) .returning(|path| path.ends_with(".kw/deploy.config")); - fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::PermissionDenied, - "denied", - ))) - }); + fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/kernel/.kw/deploy.config")) + .times(1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::PermissionDenied, + "denied", + ))) + }); let env = MockEnvTrait::new(); assert_eq!( @@ -1727,15 +1835,24 @@ last_line_without_newline=yes"; fn probe_boot_once_treats_empty_xdg_config_home_as_unset() { let mut fs = MockFileSystemTrait::new(); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/.config/kw/deploy.config") + || path == std::path::Path::new("/kernel/.kw/deploy.config") + }) + .times(2) .returning(|path| path == Path::new("/home/user/.config/kw/deploy.config")); fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/home/user/.config/kw/deploy.config")) + .times(1) .returning(|_| Ok("boot_into_new_kernel_once=no\n".to_string())); let mut env = MockEnvTrait::new(); env.expect_var() .withf(|key| key == "XDG_CONFIG_HOME") + .times(1) .returning(|_| Ok(String::new())); env.expect_var() .withf(|key| key == "HOME") + .times(1) .returning(|_| Ok("/home/user".to_string())); assert_eq!( diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 2039752..8fbeaf7 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -482,12 +482,27 @@ Host dut extra let read = Arc::new(map); let mut fs = MockFileSystemTrait::new(); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/.config/kw/remote.config") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/xdg/kw/remote.config") + }) + .times(1..=2) .returning(move |path| is_file.contains_key(path)); - fs.expect_read_to_string().returning(move |path| { - read.get(path).cloned().ok_or_else(|| { - FileSystemError::IoError(io::Error::new(io::ErrorKind::NotFound, "missing")) + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/.config/kw/remote.config") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/xdg/kw/remote.config") }) - }); + .times(0..=1) + .returning(move |path| { + read.get(path).cloned().ok_or_else(|| { + FileSystemError::IoError(io::Error::new(io::ErrorKind::NotFound, "missing")) + }) + }); fs } @@ -495,11 +510,14 @@ Host dut extra let xdg = xdg.map(str::to_string); let home = home.map(str::to_string); let mut env = MockEnvTrait::new(); - env.expect_var().returning(move |key| match key { - "XDG_CONFIG_HOME" => xdg.clone().ok_or_else(missing_var), - "HOME" => home.clone().ok_or_else(missing_var), - _ => Err(missing_var()), - }); + env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(0..=2) + .returning(move |key| match key { + "XDG_CONFIG_HOME" => xdg.clone().ok_or_else(missing_var), + "HOME" => home.clone().ok_or_else(missing_var), + _ => Err(missing_var()), + }); env } @@ -579,13 +597,18 @@ Host dut extra fn unreadable_local_file_does_not_fall_through() { let mut fs = MockFileSystemTrait::new(); fs.expect_is_file() + .withf(|path| path == std::path::Path::new("/kernel/.kw/remote.config")) + .times(1) .returning(|path| path == Path::new("/kernel/.kw/remote.config")); - fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::PermissionDenied, - "denied", - ))) - }); + fs.expect_read_to_string() + .withf(|path| path == std::path::Path::new("/kernel/.kw/remote.config")) + .times(1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::PermissionDenied, + "denied", + ))) + }); let env = env_with(Some("/xdg"), Some("/home/user")); assert_eq!( From 4a436ad743f60643e06396f15415a21a26dcb55e Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 14:38:08 -0300 Subject: [PATCH 54/61] test(app): assert call counts and arguments on mock expectations App, config, lore, input, terminal, and infrastructure mocks now state how often they run and which arguments they accept. Counts come from a test run. Shared fixtures use a measured range when callers read a different subset: config env keys, the apply shell, and the clean worktree filesystem. A generated patchset branch name is matched by its patchset- prefix so the count stays stable. .withf(|_| true) or .withf(|_, _| true) appears 11 times, each with .times(0). The call never happens, so the argument is not observed: execute when a dependency check must not spawn, the apply refusal that never touches the tree, and history lookups that must not run. .withf(|| true) appears 18 times on methods with no arguments (cache loads, terminal setup and shutdown, process wait and kill). There is no argument to check. Signed-off-by: lorenzoberts --- src/app/actions/apply.rs | 78 ++++- src/app/actions/reviewed_reply.rs | 5 + src/app/actor.rs | 6 + src/app/dependencies.rs | 49 ++- src/app/flows/edit_config.rs | 3 + src/app/integration_tests/actor_lifecycle.rs | 24 +- src/app/integration_tests/kw_ops.rs | 302 +++++++++++++----- src/app/integration_tests/kw_status.rs | 131 ++++++-- src/app/integration_tests/patchset_actions.rs | 226 ++++++++++--- src/app/screens/latest.rs | 3 + src/config/actor.rs | 3 + src/config/tests.rs | 31 ++ src/infrastructure/net/tests.rs | 17 +- src/infrastructure/process/tests.rs | 30 +- src/input/actor.rs | 130 +++++--- src/lore/application/actor.rs | 4 + src/lore/application/service.rs | 13 + src/lore/infrastructure/http_lore_client.rs | 2 +- src/lore/infrastructure/patchset_fetcher.rs | 21 ++ src/terminal/actor.rs | 19 +- 20 files changed, 848 insertions(+), 249 deletions(-) diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index c4051bf..c5e4ae0 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -380,8 +380,21 @@ mod tests { fn clean_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.git") + || path == std::path::Path::new("/kernel/.git/rebase-apply") + || path == std::path::Path::new("/kernel/.git/rebase-merge") + }) + .times(4..=8) .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); - fs.expect_is_file().returning(|_| false); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.git/BISECT_LOG") + || path == std::path::Path::new("/kernel/.git/MERGE_HEAD") + }) + .times(2..=4) + .returning(|_| false); fs } @@ -393,17 +406,52 @@ mod tests { let mut shell = MockShellTrait::new(); let calls_for_execute = Arc::clone(&calls); let outputs_for_execute = Arc::clone(&outputs); - shell.expect_execute().returning(move |cmd| { - calls_for_execute - .lock() - .expect("calls for execute locks") - .push(command_parts(cmd)); - Ok(outputs_for_execute - .lock() - .expect("outputs for execute locks") - .pop_front() - .expect("test should provide one output per shell command")) - }); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "git" + && (cmd.args == ["-C", "/kernel", "status", "--porcelain"] + || cmd.args + == [ + "-C", + "/kernel", + "show-ref", + "--verify", + "--quiet", + "refs/heads/main", + ] + || cmd.args == ["-C", "/kernel", "rev-parse", "--abbrev-ref", "HEAD"] + || cmd.args == ["-C", "/kernel", "switch", "main"] + || (cmd.args.len() == 5 + && cmd.args[..4] == ["-C", "/kernel", "checkout", "-b"] + && cmd.args[4].starts_with("patchset-")) + || cmd.args + == [ + "-C", + "/kernel", + "am", + "/tmp/patchset.mbx", + "--signoff", + "--3way", + ] + || cmd.args == ["-C", "/kernel", "switch", "feature"] + || cmd.args == ["-C", "/kernel", "am", "--abort"] + || (cmd.args.len() == 5 + && cmd.args[..4] == ["-C", "/kernel", "branch", "-D"] + && cmd.args[4].starts_with("patchset-"))) + }) + .times(1..=18) + .returning(move |cmd| { + calls_for_execute + .lock() + .expect("calls for execute locks") + .push(command_parts(cmd)); + Ok(outputs_for_execute + .lock() + .expect("outputs for execute locks") + .pop_front() + .expect("test should provide one output per shell command")) + }); (shell, calls) } @@ -534,10 +582,10 @@ mod tests { #[test] fn missing_target_kernel_tree_rejects_before_shell_commands() { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().times(0); - fs.expect_is_file().times(0); + fs.expect_is_dir().withf(|_| true).times(0); + fs.expect_is_file().withf(|_| true).times(0); let mut shell = MockShellTrait::new(); - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let result = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config_without_target()) diff --git a/src/app/actions/reviewed_reply.rs b/src/app/actions/reviewed_reply.rs index db7a5b5..e6e2d81 100644 --- a/src/app/actions/reviewed_reply.rs +++ b/src/app/actions/reviewed_reply.rs @@ -168,6 +168,10 @@ mod tests { let mut shell = MockShellTrait::new(); shell .expect_spawn_interactive() + .withf(|cmd| { + cmd.program == "git" + && (cmd.args == ["send-email", "first"] || cmd.args == ["send-email", "second"]) + }) .times(2) .returning(|cmd| Ok(cmd.args.last().is_some_and(|arg| arg == "first"))); let mut successful_indexes = HashSet::from([0]); @@ -187,6 +191,7 @@ mod tests { let mut shell = MockShellTrait::new(); shell .expect_spawn_interactive() + .withf(|cmd| cmd.program == "git" && cmd.args == ["send-email", "first"]) .times(1) .returning(|_| Err(ShellError::IoError(io::Error::other("failed")))); let mut successful_indexes = HashSet::new(); diff --git a/src/app/actor.rs b/src/app/actor.rs index 38109ea..2b13fa6 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -485,13 +485,19 @@ mod tests { let mut lists_store = MockMailingListsCacheStore::new(); lists_store .expect_load_available_lists() + .withf(|| true) + .times(1) .returning(move || Ok(vec![list.clone()])); let mut user_state = MockUserLoreStateStore::new(); user_state .expect_load_bookmarked_patchsets() + .withf(|| true) + .times(1) .returning(|| Ok(vec![])); user_state .expect_load_reviewed_patchsets() + .withf(|| true) + .times(1) .returning(|| Ok(HashMap::new())); let service = LoreService::new( diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 5767908..7d11db7 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -103,15 +103,22 @@ mod tests { /// version, so individual tests only need to override their own case. fn happy_env() -> (MockEnvTrait, MockShellTrait) { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| matches!(name, "b4" | "bat" | "git" | "kw")) + .times(3..=4) + .returning(|_| true); let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(|_| { - Ok(ShellOutput { - stdout: b"0.10.0\n".to_vec(), - stderr: Vec::new(), - success: true, - }) - }); + shell + .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) + .returning(|_| { + Ok(ShellOutput { + stdout: b"0.10.0\n".to_vec(), + stderr: Vec::new(), + success: true, + }) + }); (env, shell) } @@ -120,9 +127,10 @@ mod tests { let mut env = MockEnvTrait::new(); env.expect_which() .withf(|name| name == "b4") + .times(1) .returning(|_| false); let mut shell = MockShellTrait::new(); - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let err = DependencyService::check_external_deps( &env, @@ -139,6 +147,7 @@ mod tests { let (mut env, shell) = happy_env(); env.expect_which() .withf(|name| name == "git") + .times(0) .returning(|_| false); let result = DependencyService::check_external_deps( @@ -161,6 +170,7 @@ mod tests { let (mut env, shell) = happy_env(); env.expect_which() .withf(|name| name == "bat") + .times(0) .returning(|_| false); let result = @@ -174,9 +184,10 @@ mod tests { let (mut env, mut shell) = happy_env(); env.expect_which() .withf(|name| name == "kw") + .times(0) .returning(|_| false); // No kw on PATH: the version probe must not spawn anything. - shell.expect_execute().times(0); + shell.expect_execute().withf(|_| true).times(0); let result = DependencyService::check_external_deps( &env, @@ -192,13 +203,17 @@ mod tests { let (env, mut shell) = happy_env(); // Real 0.10 installs can still report the stale beta-0.9: the floor // check stays a warning regardless of what kw answers. - shell.expect_execute().returning(|_| { - Ok(ShellOutput { - stdout: b"beta-0.9\nBranch: master\nCommit: 3575d38\n".to_vec(), - stderr: Vec::new(), - success: true, - }) - }); + shell + .expect_execute() + .withf(|_| true) + .times(0) + .returning(|_| { + Ok(ShellOutput { + stdout: b"beta-0.9\nBranch: master\nCommit: 3575d38\n".to_vec(), + stderr: Vec::new(), + success: true, + }) + }); let result = DependencyService::check_external_deps( &env, diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 223cfe6..a61c7ef 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -151,9 +151,11 @@ mod tests { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(move |key| key == "HOME") + .times(2) .returning(move |_| Ok(home_s.clone())); mock.expect_var() .withf(|key| { @@ -166,6 +168,7 @@ mod tests { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(5) .returning(|_| Err(VarError::NotPresent.into())); (mock, home) } diff --git a/src/app/integration_tests/actor_lifecycle.rs b/src/app/integration_tests/actor_lifecycle.rs index c967fbe..ee74051 100644 --- a/src/app/integration_tests/actor_lifecycle.rs +++ b/src/app/integration_tests/actor_lifecycle.rs @@ -25,14 +25,22 @@ async fn main_like_lifecycle_shuts_input_down_before_terminal() { .withf(|frame| matches!(frame, TerminalFrame::Main(_))) .times(1..) .returning(|_| Ok(())); - session.expect_poll_event().returning(|_| Ok(None)); - session.expect_shutdown().times(1).returning(move || { - assert!( - input_shutdown_complete_for_terminal.load(Ordering::SeqCst), - "terminal shutdown should happen after input shutdown" - ); - Ok(()) - }); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(4) + .returning(|_| Ok(None)); + session + .expect_shutdown() + .withf(|| true) + .times(1) + .returning(move || { + assert!( + input_shutdown_complete_for_terminal.load(Ordering::SeqCst), + "terminal shutdown should happen after input shutdown" + ); + Ok(()) + }); let terminal_handle = TerminalActor::spawn(Box::new(session)); let ui_handle = UiActor::spawn(); diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index e7d9b47..93e0201 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -276,6 +276,7 @@ mod unix { let log_dir = kw_log_dir("injected-fs"); let mut fs = MockFileSystemTrait::new(); fs.expect_read_tail_to_string() + .withf(|path, _max_bytes| path.extension() == Some("log".as_ref())) .times(1) .returning(|_, _| Ok("cc1: compiling from mock\n".to_string())); let mut app = app_with_details_and_kw_process( @@ -551,18 +552,28 @@ mod unix { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() + .withf(|_, _| true) + .times(0) .returning(|_, _| Ok(None)); - history.expect_record_build().returning(|_| Ok(())); - history.expect_build_records().returning(|_, branch| { - if branch == "built" { - Ok(( - Some(matching_build_record("built")), - Some(matching_build_record("built")), - )) - } else { - Ok((None, None)) - } - }); + history + .expect_record_build() + .withf(|_| true) + .times(0) + .returning(|_| Ok(())); + history + .expect_build_records() + .withf(|tree, branch| tree == "linux" && (branch == "feature" || branch == "built")) + .times(2) + .returning(|_, branch| { + if branch == "built" { + Ok(( + Some(matching_build_record("built")), + Some(matching_build_record("built")), + )) + } else { + Ok((None, None)) + } + }); let mut app = app_with_kw( &log_dir, head_branch_shell("feature"), @@ -851,10 +862,18 @@ mod unix { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "linux" && branch == "feature") + .times(0..=1) .returning(|_, _| Ok(None)); - history.expect_record_build().returning(|_| Ok(())); + history + .expect_record_build() + .withf(|record| record.branch == "feature") + .times(0..=1) + .returning(|_| Ok(())); history .expect_build_records() + .withf(|tree, branch| tree == "linux" && (branch == "feature" || branch.is_empty())) + .times(1..=3) .returning(|_, _| Ok((None, None))); history } @@ -863,15 +882,25 @@ mod unix { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() + .withf(|_, _| true) + .times(0) .returning(|_, _| Ok(None)); - history.expect_record_build().returning(|_| Ok(())); - history.expect_build_records().returning(move |_, branch| { - if branch == "feature" { - Ok((record.clone(), record.clone())) - } else { - Ok((None, None)) - } - }); + history + .expect_record_build() + .withf(|_| true) + .times(0) + .returning(|_| Ok(())); + history + .expect_build_records() + .withf(|tree, branch| tree == "linux" && branch == "feature") + .times(1..=2) + .returning(move |_, branch| { + if branch == "feature" { + Ok((record.clone(), record.clone())) + } else { + Ok((None, None)) + } + }); history } @@ -902,88 +931,203 @@ mod unix { "boot_into_new_kernel_once=yes\n".to_string() }; let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.kw") + || path == std::path::Path::new("/kernel/Documentation") + || path == std::path::Path::new("/kernel/arch") + || path == std::path::Path::new("/kernel/drivers") + || path == std::path::Path::new("/kernel/fs") + || path == std::path::Path::new("/kernel/include") + || path == std::path::Path::new("/kernel/init") + || path == std::path::Path::new("/kernel/ipc") + || path == std::path::Path::new("/kernel/kernel") + || path == std::path::Path::new("/kernel/lib") + || path == std::path::Path::new("/kernel/scripts") + }) + .times(12..=24) + .returning(|_| true); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.config") + || path == std::path::Path::new("/kernel/.kw/deploy.config") + || path == std::path::Path::new("/kernel/.kw/env.current") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/kernel/COPYING") + || path == std::path::Path::new("/kernel/CREDITS") + || path == std::path::Path::new("/kernel/Kbuild") + || path == std::path::Path::new("/kernel/Makefile") + || path == std::path::Path::new("/kernel/README") + || path == std::path::Path::new("/kernel/arch/x86/boot/bzImage") + }) + .times(10..=20) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(move |path| { - if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else if path.ends_with("remote.config") { - Ok( - "#kw-default=dut\nHost dut\n Hostname box\n Port 22\n User root\n" - .to_string(), - ) - } else if path.ends_with("deploy.config") { - Ok(deploy_config.clone()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_read_dir().returning(|path| { - if path.ends_with("arch/x86/boot") { - Ok(vec![PathBuf::from("/kernel/arch/x86/boot/bzImage")]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) + .times(1..=2) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/build.config") + || path == std::path::Path::new("/kernel/.kw/deploy.config") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/kernel/include/config/kernel.release") + || path.extension() == Some("log".as_ref()) + }) + .times(3..=8) + .returning(move |path| { + if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else if path.ends_with("remote.config") { + Ok( + "#kw-default=dut\nHost dut\n Hostname box\n Port 22\n User root\n" + .to_string(), + ) + } else if path.ends_with("deploy.config") { + Ok(deploy_config.clone()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/kernel/arch/x86/boot")) + .times(1..=2) + .returning(|path| { + if path.ends_with("arch/x86/boot") { + Ok(vec![PathBuf::from("/kernel/arch/x86/boot/bzImage")]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); fs.expect_metadata() + .withf(|path| path == std::path::Path::new("/kernel/arch/x86/boot/bzImage")) + .times(1..=2) .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(0..=2) + .returning(|_| Ok(())); fs } fn head_branch_shell(branch: &str) -> MockShellTrait { let branch = branch.to_string(); let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(move |cmd| { - let stdout = match cmd.program.as_str() { - "kw" => b"kw, version 0.10.0\n".to_vec(), - _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), - _ => format!("{branch}\n").into_bytes(), - }; - Ok(ShellOutput { - stdout, - stderr: Vec::new(), - success: true, + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "git" && cmd.args == ["-C", "/kernel", "branch", "--show-current"] + || cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/kernel", "switch", "--", "feature"] }) - }); + .times(2..=10) + .returning(move |cmd| { + let stdout = match cmd.program.as_str() { + "kw" => b"kw, version 0.10.0\n".to_vec(), + _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), + _ => format!("{branch}\n").into_bytes(), + }; + Ok(ShellOutput { + stdout, + stderr: Vec::new(), + success: true, + }) + }); shell } fn kw_actor_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.kw") + || path == std::path::Path::new("/kernel/Documentation") + || path == std::path::Path::new("/kernel/arch") + || path == std::path::Path::new("/kernel/drivers") + || path == std::path::Path::new("/kernel/fs") + || path == std::path::Path::new("/kernel/include") + || path == std::path::Path::new("/kernel/init") + || path == std::path::Path::new("/kernel/ipc") + || path == std::path::Path::new("/kernel/kernel") + || path == std::path::Path::new("/kernel/lib") + || path == std::path::Path::new("/kernel/scripts") + }) + .times(12..=48) + .returning(|_| true); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.config") + || path == std::path::Path::new("/kernel/.kw/deploy.config") + || path == std::path::Path::new("/kernel/.kw/env.current") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/kernel/COPYING") + || path == std::path::Path::new("/kernel/CREDITS") + || path == std::path::Path::new("/kernel/Kbuild") + || path == std::path::Path::new("/kernel/Makefile") + || path == std::path::Path::new("/kernel/README") + }) + .times(9..=34) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) + .times(1..=4) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/build.config") + || path == std::path::Path::new("/kernel/.kw/deploy.config") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/kernel/include/config/kernel.release") + }) + .times(3..=11) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/kernel/arch")) + .times(1..=4) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(0..=1) + .returning(|_| Ok(())); fs } fn kw_actor_env() -> MockEnvTrait { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1..=4) + .returning(|_| true); env } diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index aff228f..0d146df 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -295,8 +295,14 @@ fn app_with_kw(log_dir: &Path) -> (App, KwHandle, Arc) { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "linux" && branch == "patchset-2026-08-20-15-00-00") + .times(1) .returning(|_, _| Ok(None)); - history.expect_record_build().returning(|_| Ok(())); + history + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-20-15-00-00") + .times(1) + .returning(|_| Ok(())); let kw = KwActor::spawn( Arc::new(history), process.clone(), @@ -419,45 +425,114 @@ fn apply_config() -> ConfigSnapshot { fn kw_actor_shell() -> MockShellTrait { let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(|cmd| { - let stdout = match cmd.program.as_str() { - "kw" => b"kw, version 0.10.0\n".to_vec(), - _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), - _ => b"main\n".to_vec(), - }; - Ok(ShellOutput { - stdout, - stderr: Vec::new(), - success: true, + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" && cmd.args == ["-C", "/kernel", "branch", "--show-current"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "switch", + "--", + "patchset-2026-08-20-15-00-00", + ] }) - }); + .times(4) + .returning(|cmd| { + let stdout = match cmd.program.as_str() { + "kw" => b"kw, version 0.10.0\n".to_vec(), + _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), + _ => b"main\n".to_vec(), + }; + Ok(ShellOutput { + stdout, + stderr: Vec::new(), + success: true, + }) + }); shell } fn kw_actor_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.kw") + || path == std::path::Path::new("/kernel/Documentation") + || path == std::path::Path::new("/kernel/arch") + || path == std::path::Path::new("/kernel/drivers") + || path == std::path::Path::new("/kernel/fs") + || path == std::path::Path::new("/kernel/include") + || path == std::path::Path::new("/kernel/init") + || path == std::path::Path::new("/kernel/ipc") + || path == std::path::Path::new("/kernel/kernel") + || path == std::path::Path::new("/kernel/lib") + || path == std::path::Path::new("/kernel/scripts") + }) + .times(12) + .returning(|_| true); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.config") + || path == std::path::Path::new("/kernel/.kw/env.current") + || path == std::path::Path::new("/kernel/COPYING") + || path == std::path::Path::new("/kernel/CREDITS") + || path == std::path::Path::new("/kernel/Kbuild") + || path == std::path::Path::new("/kernel/Makefile") + || path == std::path::Path::new("/kernel/README") + }) + .times(7) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/build.config") + || path == std::path::Path::new("/kernel/include/config/kernel.release") + }) + .times(1..=2) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/kernel/arch")) + .times(0..=1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); fs } fn kw_actor_env() -> MockEnvTrait { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env } diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index 45fae5b..e37ae9d 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -216,6 +216,9 @@ async fn apply_success_with_history_write_failure_keeps_success_popup() { let mut kw_history = MockKwHistoryStore::new(); kw_history .expect_record_apply() + .withf(|record| { + record.message_id == "http://lore.kernel.org/test-list/1234-1-foo@bar.example" + }) .times(1) .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); let mut app = app_with_details( @@ -259,7 +262,7 @@ async fn apply_failure_does_not_record_history() { output("", "", true), ]); let mut kw_history = MockKwHistoryStore::new(); - kw_history.expect_record_apply().times(0); + kw_history.expect_record_apply().withf(|_| true).times(0); let mut app = app_with_details( clean_fs(), shell, @@ -330,11 +333,23 @@ async fn apply_is_allowed_again_after_the_job_finishes() { let log_dir = kw_log_dir("apply-after-job"); let process = Arc::new(FakeProcess::new()); let mut store = MockKwHistoryStore::new(); - store.expect_record_apply().returning(|_| Ok(())); + store + .expect_record_apply() + .withf(|record| { + record.message_id == "http://lore.kernel.org/test-list/1234-1-foo@bar.example" + }) + .times(1) + .returning(|_| Ok(())); store .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "linux" && branch == "patchset-2026-08-20-15-00-00") + .times(1) .returning(|_, _| Ok(None)); - store.expect_record_build().returning(|_| Ok(())); + store + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-20-15-00-00") + .times(1) + .returning(|_| Ok(())); let mut app = app_with_details_and_kw( clean_fs(), shell, @@ -415,6 +430,7 @@ async fn reviewed_reply_success_records_persists_and_resets_reply_action() { .returning(|_| Ok(output("/tmp/reviewed-reply\n", "", true))); shell .expect_spawn_interactive() + .withf(|cmd| cmd.program == "git" && cmd.args == ["send-email", "--annotate"]) .times(1) .returning(|_| Ok(true)); let mut app = app_with_reviewed_reply_details(shell, lore_api); @@ -450,6 +466,7 @@ async fn reviewed_reply_failure_does_not_record_failed_index() { .returning(|_| Ok(output("/tmp/reviewed-reply\n", "", true))); shell .expect_spawn_interactive() + .withf(|cmd| cmd.program == "git" && cmd.args == ["send-email", "--annotate"]) .times(1) .returning(|_| Ok(false)); let mut app = app_with_reviewed_reply_details(shell, lore_api); @@ -635,7 +652,13 @@ fn reviewed_reply_lore_handle(saved_reviewed: SharedReviewedState) -> LoreApiHan // serde default (true) that existing config files inherit. fn history_store_allowing_writes() -> MockKwHistoryStore { let mut store = MockKwHistoryStore::new(); - store.expect_record_apply().returning(|_| Ok(())); + store + .expect_record_apply() + .withf(|record| { + record.message_id == "http://lore.kernel.org/test-list/1234-1-foo@bar.example" + }) + .times(0..=1) + .returning(|_| Ok(())); store } @@ -677,8 +700,21 @@ fn apply_config_stay_disabled() -> ConfigSnapshot { fn clean_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.git") + || path == std::path::Path::new("/kernel/.git/rebase-apply") + || path == std::path::Path::new("/kernel/.git/rebase-merge") + }) + .times(0..=4) .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); - fs.expect_is_file().returning(|_| false); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.git/BISECT_LOG") + || path == std::path::Path::new("/kernel/.git/MERGE_HEAD") + }) + .times(0..=2) + .returning(|_| false); fs } @@ -688,17 +724,52 @@ fn shell_with_outputs(outputs: Vec) -> (MockShellTrait, Arc Vec { /// tree — the happy path through the actor's start probes. fn kw_actor_shell() -> MockShellTrait { let mut shell = MockShellTrait::new(); - shell.expect_execute().returning(|cmd| { - let stdout = match cmd.program.as_str() { - "kw" => b"kw, version 0.10.0\n".to_vec(), - _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), - _ => b"main\n".to_vec(), - }; - Ok(ShellOutput { - stdout, - stderr: Vec::new(), - success: true, + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" && cmd.args == ["-C", "/kernel", "branch", "--show-current"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "switch", + "--", + "patchset-2026-08-20-15-00-00", + ] }) - }); + .times(4) + .returning(|cmd| { + let stdout = match cmd.program.as_str() { + "kw" => b"kw, version 0.10.0\n".to_vec(), + _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), + _ => b"main\n".to_vec(), + }; + Ok(ShellOutput { + stdout, + stderr: Vec::new(), + success: true, + }) + }); shell } fn kw_actor_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir().returning(|_| true); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.kw") + || path == std::path::Path::new("/kernel/Documentation") + || path == std::path::Path::new("/kernel/arch") + || path == std::path::Path::new("/kernel/drivers") + || path == std::path::Path::new("/kernel/fs") + || path == std::path::Path::new("/kernel/include") + || path == std::path::Path::new("/kernel/init") + || path == std::path::Path::new("/kernel/ipc") + || path == std::path::Path::new("/kernel/kernel") + || path == std::path::Path::new("/kernel/lib") + || path == std::path::Path::new("/kernel/scripts") + }) + .times(12) + .returning(|_| true); fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.config") + || path == std::path::Path::new("/kernel/.kw/env.current") + || path == std::path::Path::new("/kernel/COPYING") + || path == std::path::Path::new("/kernel/CREDITS") + || path == std::path::Path::new("/kernel/Kbuild") + || path == std::path::Path::new("/kernel/Makefile") + || path == std::path::Path::new("/kernel/README") + }) + .times(7) .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists().returning(|_| true); - fs.expect_read_to_string().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_create_dir_all().returning(|_| Ok(())); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/build.config") + || path == std::path::Path::new("/kernel/include/config/kernel.release") + }) + .times(1..=2) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/kernel/arch")) + .times(0..=1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); fs } fn kw_actor_env() -> MockEnvTrait { let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); env } diff --git a/src/app/screens/latest.rs b/src/app/screens/latest.rs index 4f20f13..b624911 100644 --- a/src/app/screens/latest.rs +++ b/src/app/screens/latest.rs @@ -178,6 +178,7 @@ mod tests { let mut feed_gateway = MockFeedGateway::new(); feed_gateway .expect_fetch_patch_feed_page() + .withf(|list, offset| list == "some-list" && *offset == 0) .times(1) .returning(|_, _| Ok(patch_feed_response())); @@ -194,6 +195,7 @@ mod tests { let mut feed_gateway = MockFeedGateway::new(); feed_gateway .expect_fetch_patch_feed_page() + .withf(|list, offset| list == "some-list" && *offset == 0) .times(1) .returning(|_, _| Err(LoreHttpError::EndOfFeed)); @@ -212,6 +214,7 @@ mod tests { let mut feed_gateway = MockFeedGateway::new(); feed_gateway .expect_fetch_patch_feed_page() + .withf(|list, offset| list == "some-list" && *offset == 0) .times(1) .returning(|_, _| { Err(LoreHttpError::Net(NetError::HttpStatus { diff --git a/src/config/actor.rs b/src/config/actor.rs index 3b30a1c..68d9296 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -143,9 +143,11 @@ mod tests { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(move |key| key == "HOME") + .times(2) .returning(move |_| Ok(home_s.clone())); mock.expect_var() .withf(|key| { @@ -158,6 +160,7 @@ mod tests { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(5) .returning(|_| Err(VarError::NotPresent.into())); (mock, home) } diff --git a/src/config/tests.rs b/src/config/tests.rs index 9fa7b89..042dd9d 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -71,9 +71,11 @@ fn default_env() -> (MockEnvTrait, PathBuf) { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(0..=1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(move |key| key == "HOME") + .times(1..=2) .returning(move |_| Ok(home_s.clone())); mock.expect_var() .withf(|key| { @@ -86,6 +88,7 @@ fn default_env() -> (MockEnvTrait, PathBuf) { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(0..=5) .returning(|_| Err(VarError::NotPresent.into())); (mock, home) } @@ -188,9 +191,11 @@ fn bootstrap_with_config_file() { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(move |_| Ok(tmp_path_s.clone())); mock.expect_var() .withf(|key| key == "HOME") + .times(0) .returning(move |_| Ok(home_s.clone())); mock.expect_var() .withf(|key| { @@ -203,6 +208,7 @@ fn bootstrap_with_config_file() { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(5) .returning(|_| Err(VarError::NotPresent.into())); let config = bootstrap_snapshot(&mock); @@ -280,24 +286,31 @@ fn bootstrap_with_env_vars() { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(move |key| key == "HOME") + .times(2) .returning(move |_| Ok(home_s.clone())); mock.expect_var() .withf(|key| key == "PATCH_HUB_PAGE_SIZE") + .times(1) .returning(|_| Ok("42".to_string())); mock.expect_var() .withf(move |key| key == "PATCH_HUB_CACHE_DIR") + .times(1) .returning(move |_| Ok(cache_s.clone())); mock.expect_var() .withf(move |key| key == "PATCH_HUB_DATA_DIR") + .times(1) .returning(move |_| Ok(data_s.clone())); mock.expect_var() .withf(|key| key == "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS") + .times(1) .returning(|_| Ok("--option1 --option2".to_string())); mock.expect_var() .withf(|key| key == "PATCH_HUB_PATCH_RENDERER") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); let config = bootstrap_snapshot(&mock); @@ -344,10 +357,12 @@ fn bootstrap_config_precedence() { env_with_file .expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(move |_| Ok(tmp_path_s.clone())); env_with_file .expect_var() .withf(move |key| key == "HOME") + .times(0) .returning(move |_| Ok(home_s.clone())); env_with_file .expect_var() @@ -361,6 +376,7 @@ fn bootstrap_config_precedence() { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(5) .returning(|_| Err(VarError::NotPresent.into())); assert_eq!(1234, bootstrap_snapshot(&env_with_file).page_size()); @@ -371,14 +387,17 @@ fn bootstrap_config_precedence() { env_with_file_and_var .expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(move |_| Ok(tmp_path_s2.clone())); env_with_file_and_var .expect_var() .withf(|key| key == "PATCH_HUB_PAGE_SIZE") + .times(1) .returning(|_| Ok("42".to_string())); env_with_file_and_var .expect_var() .withf(move |key| key == "HOME") + .times(0) .returning(move |_| Ok(home_s2.clone())); env_with_file_and_var .expect_var() @@ -391,6 +410,7 @@ fn bootstrap_config_precedence() { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(4) .returning(|_| Err(VarError::NotPresent.into())); assert_eq!(42, bootstrap_snapshot(&env_with_file_and_var).page_size()); @@ -422,6 +442,7 @@ fn bootstrap_rejects_invalid_patch_hub_page_size_env() { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(|key| key == "HOME") @@ -429,6 +450,7 @@ fn bootstrap_rejects_invalid_patch_hub_page_size_env() { .returning(move |_| Ok(home_s.clone())); mock.expect_var() .withf(|key| key == "PATCH_HUB_PAGE_SIZE") + .times(1) .returning(|_| Ok("not-a-number".into())); mock.expect_var() .withf(|key| { @@ -440,6 +462,7 @@ fn bootstrap_rejects_invalid_patch_hub_page_size_env() { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(0) .returning(|_| Err(VarError::NotPresent.into())); match ConfigService::bootstrap_parts(&mock, os_fs()) { @@ -457,6 +480,7 @@ fn bootstrap_rejects_invalid_patch_hub_patch_renderer_env() { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(|key| key == "HOME") @@ -464,18 +488,23 @@ fn bootstrap_rejects_invalid_patch_hub_patch_renderer_env() { .returning(move |_| Ok(home_s.clone())); mock.expect_var() .withf(|key| key == "PATCH_HUB_PAGE_SIZE") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(|key| key == "PATCH_HUB_CACHE_DIR") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(|key| key == "PATCH_HUB_DATA_DIR") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(|key| key == "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS") + .times(1) .returning(|_| Err(VarError::NotPresent.into())); mock.expect_var() .withf(|key| key == "PATCH_HUB_PATCH_RENDERER") + .times(1) .returning(|_| Ok("not-a-real-renderer".into())); match ConfigService::bootstrap_parts(&mock, os_fs()) { @@ -778,9 +807,11 @@ fn json_config_repository_save_creates_parent_and_leaves_no_tmp_stale() { let mut mock = MockEnvTrait::new(); mock.expect_var() .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) .returning(move |_| Ok(cfg_s.clone())); mock.expect_var() .withf(|key| key == "HOME") + .times(1) .returning(move |_| Ok(home_s.clone())); let repo = JsonConfigRepository::new(&mock, os_fs()); diff --git a/src/infrastructure/net/tests.rs b/src/infrastructure/net/tests.rs index f7d6771..4565a88 100644 --- a/src/infrastructure/net/tests.rs +++ b/src/infrastructure/net/tests.rs @@ -4,6 +4,8 @@ use super::{HttpMethod, MockNetClientTrait, NetClientTrait, NetError}; fn mock_net_client_returns_configured_response() { let mut mock = MockNetClientTrait::new(); mock.expect_request() + .withf(|_method, url| url == "https://example.com") + .times(1) .returning(|_, _| Ok("response".to_string())); let result = mock.request(HttpMethod::Get, "https://example.com"); @@ -14,12 +16,15 @@ fn mock_net_client_returns_configured_response() { #[test] fn mock_net_client_can_return_http_status_error() { let mut mock = MockNetClientTrait::new(); - mock.expect_request().returning(|_, _| { - Err(NetError::HttpStatus { - code: 404, - message: "HTTP 404".to_string(), - }) - }); + mock.expect_request() + .withf(|_method, url| url == "https://example.com/missing") + .times(1) + .returning(|_, _| { + Err(NetError::HttpStatus { + code: 404, + message: "HTTP 404".to_string(), + }) + }); let result = mock.request(HttpMethod::Get, "https://example.com/missing"); assert!(result.is_err()); diff --git a/src/infrastructure/process/tests.rs b/src/infrastructure/process/tests.rs index 2e660f6..1c32716 100644 --- a/src/infrastructure/process/tests.rs +++ b/src/infrastructure/process/tests.rs @@ -238,8 +238,14 @@ async fn dropped_unreaped_process_group_is_killed() { #[tokio::test] async fn running_process_is_dyn_compatible_and_mockable() { let mut mock = MockRunningProcess::new(); - mock.expect_wait().returning(|| Ok(ExitStatus::from_raw(0))); - mock.expect_kill().returning(|| Ok(())); + mock.expect_wait() + .withf(|| true) + .times(1) + .returning(|| Ok(ExitStatus::from_raw(0))); + mock.expect_kill() + .withf(|| true) + .times(1) + .returning(|| Ok(())); let mut process: Box = Box::new(mock); process.kill().expect("process kills"); @@ -366,12 +372,20 @@ async fn mock_process_trait_can_simulate_spawn_failure() { let cmd = ShellCommand::new("kw").arg("build"); let mut mock = MockProcessTrait::new(); - mock.expect_spawn().return_once(|_, _, _| { - Err(ProcessError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "kw not found", - ))) - }); + mock.expect_spawn() + .withf(|cmd, cwd, log_path| { + cmd.program == "kw" + && cmd.args == ["build"] + && cwd.starts_with(std::env::temp_dir()) + && log_path.starts_with(std::env::temp_dir()) + }) + .times(1) + .return_once(|_, _, _| { + Err(ProcessError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "kw not found", + ))) + }); let result = mock.spawn(&cmd, dir.path(), &log); diff --git a/src/input/actor.rs b/src/input/actor.rs index 242fd9c..663cf4b 100644 --- a/src/input/actor.rs +++ b/src/input/actor.rs @@ -177,13 +177,21 @@ mod tests { async fn key_event_in_mailing_list_context_delivers_navigate_down() { let mut session = MockTerminalSessionApi::new(); // First poll returns the key; subsequent polls time-out (return None). - session.expect_poll_event().times(1).returning(|_| { - Ok(Some(TerminalEvent::Key(KeyInput { - code: KeyCode::Down, - ..Default::default() - }))) - }); - session.expect_poll_event().returning(|_| Ok(None)); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1) + .returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Down, + ..Default::default() + }))) + }); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1..) + .returning(|_| Ok(None)); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); @@ -203,14 +211,26 @@ mod tests { // First call returns None so the actor can process the context update // before the key arrives. Second call returns the Esc key. // Remaining calls time-out. - session.expect_poll_event().times(1).returning(|_| Ok(None)); - session.expect_poll_event().times(1).returning(|_| { - Ok(Some(TerminalEvent::Key(KeyInput { - code: KeyCode::Esc, - ..Default::default() - }))) - }); - session.expect_poll_event().returning(|_| Ok(None)); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1) + .returning(|_| Ok(None)); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1) + .returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Esc, + ..Default::default() + }))) + }); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1..) + .returning(|_| Ok(None)); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); @@ -234,21 +254,33 @@ mod tests { async fn unmapped_terminal_event_is_discarded_without_error() { let mut session = MockTerminalSessionApi::new(); // F6 has no mapping in any screen — it should be silently dropped. - session.expect_poll_event().times(1).returning(|_| { - Ok(Some(TerminalEvent::Key(KeyInput { - code: KeyCode::F(6), - ..Default::default() - }))) - }); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1) + .returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::F(6), + ..Default::default() + }))) + }); // Second event — Down — is delivered so we can wait for it to confirm // the actor kept running after discarding the unmapped event. - session.expect_poll_event().times(1).returning(|_| { - Ok(Some(TerminalEvent::Key(KeyInput { - code: KeyCode::Down, - ..Default::default() - }))) - }); - session.expect_poll_event().returning(|_| Ok(None)); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1) + .returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Down, + ..Default::default() + }))) + }); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1..) + .returning(|_| Ok(None)); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); @@ -265,7 +297,11 @@ mod tests { #[tokio::test] async fn shutdown_closes_subscriber_channel() { let mut session = MockTerminalSessionApi::new(); - session.expect_poll_event().returning(|_| Ok(None)); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1..) + .returning(|_| Ok(None)); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); @@ -287,11 +323,15 @@ mod tests { async fn terminal_poll_error_stops_input_actor_and_closes_subscriber() { let mut session = MockTerminalSessionApi::new(); // First poll returns an error; the pump detects it and stops. - session.expect_poll_event().times(1).returning(|_| { - Err(TerminalError::Session( - "simulated terminal failure".to_string(), - )) - }); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1) + .returning(|_| { + Err(TerminalError::Session( + "simulated terminal failure".to_string(), + )) + }); let (input_handle, _terminal_handle) = spawn_test_actor(session, mailing_list_context()); let (sub_tx, mut sub_rx) = mpsc::channel::(8); @@ -309,13 +349,21 @@ mod tests { #[tokio::test] async fn popup_open_context_maps_escape_to_close_popup() { let mut session = MockTerminalSessionApi::new(); - session.expect_poll_event().times(1).returning(|_| { - Ok(Some(TerminalEvent::Key(KeyInput { - code: KeyCode::Esc, - ..Default::default() - }))) - }); - session.expect_poll_event().returning(|_| Ok(None)); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1) + .returning(|_| { + Ok(Some(TerminalEvent::Key(KeyInput { + code: KeyCode::Esc, + ..Default::default() + }))) + }); + session + .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) + .times(1..) + .returning(|_| Ok(None)); let context = InputContext { popup_open: true, diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index 3ebecd4..d210650 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -302,16 +302,19 @@ mod tests { let mut lists_store = MockMailingListsCacheStore::new(); lists_store .expect_load_available_lists() + .withf(|| true) .times(1) .returning(|| Ok(vec![MailingList::new("linux-mm", "")])); let mut user_state = MockUserLoreStateStore::new(); user_state .expect_load_bookmarked_patchsets() + .withf(|| true) .times(1) .returning(|| Ok(vec![])); user_state .expect_load_reviewed_patchsets() + .withf(|| true) .times(1) .returning(|| Ok(HashMap::new())); @@ -333,6 +336,7 @@ mod tests { let mut lists_store = MockMailingListsCacheStore::new(); lists_store .expect_load_available_lists() + .withf(|| true) .times(1) .returning(|| Ok(vec![MailingList::new("cached-list", "")])); diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index d1e0d26..a1ed9c0 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -517,6 +517,7 @@ mod tests { let mut lists_store = MockMailingListsCacheStore::new(); lists_store .expect_load_available_lists() + .withf(|| true) .times(1) .returning(|| Ok(vec![MailingList::new("linux-mm", "desc")])); @@ -600,6 +601,7 @@ mod tests { let mut lists_store = MockMailingListsCacheStore::new(); lists_store .expect_save_available_lists() + .withf(|lists| lists.len() == 320) .times(1) .returning(|_| Ok(())); @@ -627,12 +629,14 @@ mod tests { let mut lists_gateway = MockListsGateway::new(); lists_gateway .expect_fetch_available_lists_page() + .withf(|offset| *offset == 0) .times(1) .returning(|_| Ok(String::new())); // empty page → break loop let mut lists_store = MockMailingListsCacheStore::new(); lists_store .expect_save_available_lists() + .withf(|lists| lists.is_empty()) .times(1) .returning(|_| Ok(())); @@ -666,16 +670,19 @@ mod tests { let mut lists_store = MockMailingListsCacheStore::new(); lists_store .expect_load_available_lists() + .withf(|| true) .times(1) .returning(|| Ok(vec![MailingList::new("linux-mm", "")])); let mut user_state = MockUserLoreStateStore::new(); user_state .expect_load_bookmarked_patchsets() + .withf(|| true) .times(1) .returning(|| Ok(vec![])); user_state .expect_load_reviewed_patchsets() + .withf(|| true) .times(1) .returning(|| Ok(HashMap::new())); @@ -735,6 +742,7 @@ mod tests { let mut feed_gateway = MockFeedGateway::new(); feed_gateway .expect_fetch_patch_feed_page() + .withf(|list, offset| list == "some-list" && *offset == 0) .times(1) .returning(|_, _| Err(LoreHttpError::EndOfFeed)); @@ -793,6 +801,7 @@ mod tests { let mut feed_gateway = MockFeedGateway::new(); feed_gateway .expect_fetch_patch_feed_page() + .withf(|list, offset| list == "some-list" && *offset == 0) .times(1) .returning(move |_, _| Ok(fs::read_to_string(src).expect("file reads"))); @@ -875,12 +884,14 @@ mod tests { let mut fetcher = MockPatchsetFetcher::new(); fetcher .expect_download() + .withf(|patch| patch.title() == "test") .times(1) .returning(|_| Ok("/tmp/new.mbx".to_string())); let mut parser = MockPatchsetParser::new(); parser .expect_split_patchset() + .withf(|path| path == "/tmp/new.mbx") .times(1) .returning(|_| Ok(vec!["raw".to_string()])); @@ -911,12 +922,14 @@ mod tests { let mut fetcher = MockPatchsetFetcher::new(); fetcher .expect_download() + .withf(|patch| patch.title() == "test") .times(1) .returning(|_| Ok("/tmp/refreshed.mbx".to_string())); let mut parser = MockPatchsetParser::new(); parser .expect_split_patchset() + .withf(|path| path == "/tmp/refreshed.mbx") .times(1) .returning(|_| Ok(vec!["fresh raw".to_string()])); diff --git a/src/lore/infrastructure/http_lore_client.rs b/src/lore/infrastructure/http_lore_client.rs index 0ce4654..90f1a0d 100644 --- a/src/lore/infrastructure/http_lore_client.rs +++ b/src/lore/infrastructure/http_lore_client.rs @@ -139,7 +139,7 @@ mod tests { #[test] fn fetch_patch_feed_page_returns_end_of_feed_on_empty_body() { let mut mock = MockNetClientTrait::new(); - mock.expect_request() + mock.expect_request().withf(|_method, url| url == "https://lore.kernel.org/linux-kernel/?x=A&q=((s:patch+OR+s:rfc)+AND+NOT+s:re:)&o=0") .times(1) .returning(|_, _| Ok("".to_string())); diff --git a/src/lore/infrastructure/patchset_fetcher.rs b/src/lore/infrastructure/patchset_fetcher.rs index b8326bd..7785bbf 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -147,11 +147,13 @@ mod tests { mock_fs .expect_exists() .withf(|p| p == Path::new(CACHE_DIR)) + .times(1) .returning(|_| true); // file already cached mock_fs .expect_exists() .withf(|p| p == Path::new(CACHED_FILE)) + .times(2) .returning(|_| true); let mock_shell = MockShellTrait::new(); // b4 must NOT be called @@ -180,10 +182,12 @@ mod tests { mock_fs .expect_exists() .withf(|p| p == Path::new(CACHE_DIR)) + .times(1) .returning(|_| true); mock_fs .expect_exists() .withf(|p| p == Path::new(CACHED_FILE)) + .times(2) .returning(move |_| { let n = call_count_clone.fetch_add(1, atomic::Ordering::SeqCst); n > 0 // false on first call (before b4), true on second (after b4) @@ -220,15 +224,32 @@ mod tests { mock_fs .expect_exists() .withf(|p| p == Path::new(CACHE_DIR)) + .times(1) .returning(|_| true); mock_fs .expect_exists() .withf(|p| p == Path::new(CACHED_FILE)) + .times(1) .returning(|_| false); let mut mock_shell = MockShellTrait::new(); mock_shell .expect_execute() + .withf(|cmd| { + cmd.program == "b4" + && cmd.args + == [ + "--quiet", + "am", + "--use-version", + "1", + "https://lore.kernel.org/linux-kernel/1234.567-1-john@johnson.com/", + "--outdir", + "/cache", + "--mbox-name", + "linux-kernel.1234.567-1-john@johnson.com.mbx", + ] + }) .times(1) .returning(|_| Err(ShellError::IoError(io::Error::other("b4 not found")))); diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index da9e13f..c661f34 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -211,7 +211,11 @@ mod tests { .withf(|frame| matches!(frame, TerminalFrame::Empty)) .times(1) .returning(|_| Ok(())); - session.expect_size().times(1).returning(|| Ok((120, 40))); + session + .expect_size() + .withf(|| true) + .times(1) + .returning(|| Ok((120, 40))); let handle = spawn_test_actor(session); handle @@ -244,7 +248,11 @@ mod tests { #[tokio::test] async fn setup_user_io_delegates_to_session() { let mut session = MockTerminalSessionApi::new(); - session.expect_setup_user_io().times(1).returning(|| Ok(())); + session + .expect_setup_user_io() + .withf(|| true) + .times(1) + .returning(|| Ok(())); let handle = spawn_test_actor(session); handle.setup_user_io().await.expect("user io sets up"); @@ -255,6 +263,7 @@ mod tests { let mut session = MockTerminalSessionApi::new(); session .expect_teardown_user_io() + .withf(|| true) .times(1) .returning(|| Ok(())); let handle = spawn_test_actor(session); @@ -265,7 +274,11 @@ mod tests { #[tokio::test] async fn shutdown_delegates_to_session_and_is_safe_to_repeat() { let mut session = MockTerminalSessionApi::new(); - session.expect_shutdown().times(2).returning(|| Ok(())); + session + .expect_shutdown() + .withf(|| true) + .times(2) + .returning(|| Ok(())); let handle = spawn_test_actor(session); handle.shutdown().await.expect("actor shuts down"); From 13da4391d2fd963d8545096851d5477a4ca0e044 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 14:50:38 -0300 Subject: [PATCH 55/61] test: move fixture builders into helpers submodules Test-only builders and doubles now live in a helpers submodule next to the tests that call them. None became Default: the built types do not implement Default, or they are parsed from JSON or XML, and this crate does not use cfg_attr(test, derive(Default)). No test was renamed. Signed-off-by: lorenzoberts --- src/app/actions/apply.rs | 312 ++-- src/app/actions/reviewed_reply.rs | 14 +- src/app/actor.rs | 314 ++-- src/app/dependencies.rs | 54 +- src/app/flows/edit_config.rs | 262 ++-- src/app/flows/kw_ops.rs | 71 +- src/app/integration_tests/flow_errors.rs | 34 +- src/app/integration_tests/kw_ops.rs | 61 +- src/app/integration_tests/kw_status.rs | 653 ++++---- src/app/integration_tests/patchset_actions.rs | 1107 +++++++------- src/app/mod.rs | 67 +- src/app/screens/details_actions.rs | 66 +- src/app/screens/edit_config.rs | 44 +- src/app/screens/kw_ops.rs | 64 +- src/app/screens/latest.rs | 102 +- src/app/view_model.rs | 192 +-- src/config/actor.rs | 119 +- src/config/tests.rs | 254 ++-- src/infrastructure/file_system/tests.rs | 47 +- src/infrastructure/process/tests.rs | 121 +- src/input/actor.rs | 60 +- src/input/mapper.rs | 56 +- src/kw/actor/actor_test.rs | 1329 +++++++++-------- src/kw/argv.rs | 23 +- src/kw/history.rs | 137 +- src/kw/log_scan.rs | 134 +- src/kw/readiness.rs | 258 ++-- src/kw/remote.rs | 209 +-- src/kw/status.rs | 30 +- src/lore/application/actor.rs | 77 +- src/lore/application/service.rs | 104 +- src/lore/domain/patchset.rs | 17 +- src/lore/infrastructure/patchset_fetcher.rs | 50 +- src/lore/infrastructure/patchset_parser.rs | 27 +- src/lore/infrastructure/persistence.rs | 75 +- src/render/actor.rs | 21 +- src/terminal/actor.rs | 15 +- src/ui/actor.rs | 53 +- src/ui/core.rs | 51 +- src/ui/screens/edit_config.rs | 22 +- 40 files changed, 3518 insertions(+), 3188 deletions(-) diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index c5e4ae0..86df17c 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -298,172 +298,178 @@ impl ApplyPatchsetService { #[cfg(test)] mod tests { - use std::{ - collections::VecDeque, - sync::{Arc, Mutex}, - }; - - use crate::{ - config::{ConfigSnapshot, ConfigState}, - infrastructure::{ - file_system::MockFileSystemTrait, - shell::{MockShellTrait, ShellCommand, ShellOutput}, - }, - }; - use super::*; + mod helpers { + use super::super::*; + use crate::{ + config::{ConfigSnapshot, ConfigState}, + infrastructure::{ + file_system::MockFileSystemTrait, + shell::{MockShellTrait, ShellCommand, ShellOutput}, + }, + }; + use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + }; - const KERNEL_TREE_PATH: &str = "/kernel"; - const BASE_BRANCH: &str = "main"; - const PATCHSET_PATH: &str = "/tmp/patchset.mbx"; - - // `stay_on_applied_branch` is deliberately absent so the tests below - // exercise the serde default (true) that existing config files inherit. - fn config() -> ConfigSnapshot { - ConfigSnapshot::from( - &serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-" - })) - .expect("test config should deserialize"), - ) - } + pub(super) const KERNEL_TREE_PATH: &str = "/kernel"; + pub(super) const BASE_BRANCH: &str = "main"; + pub(super) const PATCHSET_PATH: &str = "/tmp/patchset.mbx"; + + // `stay_on_applied_branch` is deliberately absent so the tests below + // exercise the serde default (true) that existing config files inherit. + pub(super) fn config() -> ConfigSnapshot { + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-" + })) + .expect("test config should deserialize"), + ) + } - fn config_stay_disabled() -> ConfigSnapshot { - ConfigSnapshot::from( - &serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-", - "stay_on_applied_branch": false - })) - .expect("test config should deserialize"), - ) - } + pub(super) fn config_stay_disabled() -> ConfigSnapshot { + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-", + "stay_on_applied_branch": false + })) + .expect("test config should deserialize"), + ) + } - fn config_without_target() -> ConfigSnapshot { - ConfigSnapshot::from(&ConfigState::default()) - } + pub(super) fn config_without_target() -> ConfigSnapshot { + ConfigSnapshot::from(&ConfigState::default()) + } - fn request() -> ApplyPatchsetRequest { - ApplyPatchsetRequest { - patch_title: "[PATCH] test".to_string(), - patchset_path: PATCHSET_PATH.to_string(), + pub(super) fn request() -> ApplyPatchsetRequest { + ApplyPatchsetRequest { + patch_title: "[PATCH] test".to_string(), + patchset_path: PATCHSET_PATH.to_string(), + } } - } - fn output( - stdout: impl Into>, - stderr: impl Into>, - success: bool, - ) -> ShellOutput { - ShellOutput { - stdout: stdout.into(), - stderr: stderr.into(), - success, + pub(super) fn output( + stdout: impl Into>, + stderr: impl Into>, + success: bool, + ) -> ShellOutput { + ShellOutput { + stdout: stdout.into(), + stderr: stderr.into(), + success, + } } - } - fn clean_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .withf(|path| { - path == std::path::Path::new("/kernel") - || path == std::path::Path::new("/kernel/.git") - || path == std::path::Path::new("/kernel/.git/rebase-apply") - || path == std::path::Path::new("/kernel/.git/rebase-merge") - }) - .times(4..=8) - .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/kernel/.git/BISECT_LOG") - || path == std::path::Path::new("/kernel/.git/MERGE_HEAD") - }) - .times(2..=4) - .returning(|_| false); - fs - } + pub(super) fn clean_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.git") + || path == std::path::Path::new("/kernel/.git/rebase-apply") + || path == std::path::Path::new("/kernel/.git/rebase-merge") + }) + .times(4..=8) + .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.git/BISECT_LOG") + || path == std::path::Path::new("/kernel/.git/MERGE_HEAD") + }) + .times(2..=4) + .returning(|_| false); + fs + } - fn shell_with_outputs( - outputs: Vec, - ) -> (MockShellTrait, Arc>>>) { - let calls = Arc::new(Mutex::new(Vec::new())); - let outputs = Arc::new(Mutex::new(VecDeque::from(outputs))); - let mut shell = MockShellTrait::new(); - let calls_for_execute = Arc::clone(&calls); - let outputs_for_execute = Arc::clone(&outputs); - shell - .expect_execute() - .withf(|cmd| { - cmd.program == "git" - && (cmd.args == ["-C", "/kernel", "status", "--porcelain"] - || cmd.args - == [ - "-C", - "/kernel", - "show-ref", - "--verify", - "--quiet", - "refs/heads/main", - ] - || cmd.args == ["-C", "/kernel", "rev-parse", "--abbrev-ref", "HEAD"] - || cmd.args == ["-C", "/kernel", "switch", "main"] - || (cmd.args.len() == 5 - && cmd.args[..4] == ["-C", "/kernel", "checkout", "-b"] - && cmd.args[4].starts_with("patchset-")) - || cmd.args - == [ - "-C", - "/kernel", - "am", - "/tmp/patchset.mbx", - "--signoff", - "--3way", - ] - || cmd.args == ["-C", "/kernel", "switch", "feature"] - || cmd.args == ["-C", "/kernel", "am", "--abort"] - || (cmd.args.len() == 5 - && cmd.args[..4] == ["-C", "/kernel", "branch", "-D"] - && cmd.args[4].starts_with("patchset-"))) - }) - .times(1..=18) - .returning(move |cmd| { - calls_for_execute - .lock() - .expect("calls for execute locks") - .push(command_parts(cmd)); - Ok(outputs_for_execute - .lock() - .expect("outputs for execute locks") - .pop_front() - .expect("test should provide one output per shell command")) - }); - (shell, calls) - } + pub(super) fn shell_with_outputs( + outputs: Vec, + ) -> (MockShellTrait, Arc>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + let outputs = Arc::new(Mutex::new(VecDeque::from(outputs))); + let mut shell = MockShellTrait::new(); + let calls_for_execute = Arc::clone(&calls); + let outputs_for_execute = Arc::clone(&outputs); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "git" + && (cmd.args == ["-C", "/kernel", "status", "--porcelain"] + || cmd.args + == [ + "-C", + "/kernel", + "show-ref", + "--verify", + "--quiet", + "refs/heads/main", + ] + || cmd.args == ["-C", "/kernel", "rev-parse", "--abbrev-ref", "HEAD"] + || cmd.args == ["-C", "/kernel", "switch", "main"] + || (cmd.args.len() == 5 + && cmd.args[..4] == ["-C", "/kernel", "checkout", "-b"] + && cmd.args[4].starts_with("patchset-")) + || cmd.args + == [ + "-C", + "/kernel", + "am", + "/tmp/patchset.mbx", + "--signoff", + "--3way", + ] + || cmd.args == ["-C", "/kernel", "switch", "feature"] + || cmd.args == ["-C", "/kernel", "am", "--abort"] + || (cmd.args.len() == 5 + && cmd.args[..4] == ["-C", "/kernel", "branch", "-D"] + && cmd.args[4].starts_with("patchset-"))) + }) + .times(1..=18) + .returning(move |cmd| { + calls_for_execute + .lock() + .expect("calls for execute locks") + .push(command_parts(cmd)); + Ok(outputs_for_execute + .lock() + .expect("outputs for execute locks") + .pop_front() + .expect("test should provide one output per shell command")) + }); + (shell, calls) + } - fn command_parts(cmd: &ShellCommand) -> Vec { - let mut parts = vec![cmd.program.clone()]; - parts.extend(cmd.args.clone()); - parts - } + pub(super) fn command_parts(cmd: &ShellCommand) -> Vec { + let mut parts = vec![cmd.program.clone()]; + parts.extend(cmd.args.clone()); + parts + } - fn command(parts: &[&str]) -> Vec { - parts.iter().map(|part| part.to_string()).collect() + pub(super) fn command(parts: &[&str]) -> Vec { + parts.iter().map(|part| part.to_string()).collect() + } } + use helpers::*; + + use crate::infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}; + + use super::*; #[test] fn apply_success_stays_on_applied_branch_by_default() { diff --git a/src/app/actions/reviewed_reply.rs b/src/app/actions/reviewed_reply.rs index e6e2d81..7b1ef4c 100644 --- a/src/app/actions/reviewed_reply.rs +++ b/src/app/actions/reviewed_reply.rs @@ -123,16 +123,22 @@ impl ReviewedReplyService { #[cfg(test)] mod tests { + + mod helpers { + + use super::super::*; + + pub(super) fn command(name: &str) -> ShellCommand { + ShellCommand::new("git").arg("send-email").arg(name) + } + } + use helpers::*; use std::{collections::HashSet, io}; use crate::infrastructure::shell::{MockShellTrait, ShellError}; use super::*; - fn command(name: &str) -> ShellCommand { - ShellCommand::new("git").arg("send-email").arg(name) - } - #[test] fn selected_reply_indexes_preserves_original_patch_indexes() { let indexes = diff --git a/src/app/actor.rs b/src/app/actor.rs index 2b13fa6..59aaca0 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -353,102 +353,184 @@ impl AppActor { #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::Arc}; - use tokio::sync::mpsc; - - use crate::{ - app::{ - screens::{ - bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, - CurrentScreen, + mod helpers { + use super::super::*; + use crate::app::models::kw_ops::KwOpsState; + use crate::kw::models::{ + readiness::{ + BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, + TreeReadiness, }, - state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, - AppServices, - }, - config::{ConfigHandle, ConfigSnapshot, ConfigState}, - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, - kw::history::MockKwHistoryStore, - lore::{ - application::{ - actor::LoreApiActor, handle::LoreApiHandle, models::cache::CacheTtl, - service::LoreService, + remote::RemoteRefusal, + }; + use crate::{ + app::{ + screens::{ + bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, + CurrentScreen, + }, + state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, + AppServices, }, - domain::mailing_list::MailingList, - infrastructure::{ - http_lore_client::{MockFeedGateway, MockListsGateway, MockPatchHtmlGateway}, - patchset_fetcher::MockPatchsetFetcher, - patchset_parser::MockPatchsetParser, - persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, + config::{ConfigHandle, ConfigSnapshot, ConfigState}, + infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, + kw::history::MockKwHistoryStore, + lore::{ + application::{ + actor::LoreApiActor, handle::LoreApiHandle, models::cache::CacheTtl, + service::LoreService, + }, + domain::mailing_list::MailingList, + infrastructure::{ + http_lore_client::{MockFeedGateway, MockListsGateway, MockPatchHtmlGateway}, + patchset_fetcher::MockPatchsetFetcher, + patchset_parser::MockPatchsetParser, + persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, + }, }, - }, - render::{actor::RenderActor, handle::RenderHandle, ShellRenderService}, - terminal::{ - actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, - }, - ui::actor::UiActor, - }; - - use super::*; - use crate::app::models::kw_ops::{DeployStartKind, KwOpsState}; - use crate::kw::models::{ - readiness::{ - BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, - TreeReadiness, - }, - remote::RemoteRefusal, - }; + render::{actor::RenderActor, handle::RenderHandle, ShellRenderService}, + }; + use std::{collections::HashMap, sync::Arc}; + use tokio::sync::mpsc; - fn dummy_config_handle() -> ConfigHandle { - let (config_tx, _config_rx) = mpsc::channel(1); - ConfigHandle::new(config_tx) - } + pub(super) fn dummy_config_handle() -> ConfigHandle { + let (config_tx, _config_rx) = mpsc::channel(1); + ConfigHandle::new(config_tx) + } - fn minimal_app() -> App { - let (lore_tx, _lore_rx) = mpsc::channel(1); - let (render_tx, _render_rx) = mpsc::channel(1); + pub(super) fn minimal_app() -> App { + let (lore_tx, _lore_rx) = mpsc::channel(1); + let (render_tx, _render_rx) = mpsc::channel(1); - let dummy_list = MailingList::new("test-list", "Test list"); + let dummy_list = MailingList::new("test-list", "Test list"); - App { - state: AppState { - navigation: NavigationState { - current_screen: CurrentScreen::MailingListSelection, - }, - lore: LoreUiState { - mailing_list_selection: MailingListSelectionState { - mailing_lists: vec![dummy_list.clone()], - target_list: String::new(), - possible_mailing_lists: vec![dummy_list], - highlighted_list_index: 0, + App { + state: AppState { + navigation: NavigationState { + current_screen: CurrentScreen::MailingListSelection, + }, + lore: LoreUiState { + mailing_list_selection: MailingListSelectionState { + mailing_lists: vec![dummy_list.clone()], + target_list: String::new(), + possible_mailing_lists: vec![dummy_list], + highlighted_list_index: 0, + }, + latest_patchsets: None, + details: None, }, - latest_patchsets: None, - details: None, + user_state: UserLoreState { + bookmarked_patchsets: BookmarkedPatchsetsState { + bookmarked_patchsets: vec![], + patchset_index: 0, + }, + reviewed_patchsets: HashMap::new(), + }, + config_state: ConfigUiState { edit_config: None }, + config: ConfigSnapshot::from(&ConfigState::default()), + popup: None, + kw: Default::default(), + }, + services: AppServices { + lore_api: LoreApiHandle::new(lore_tx), + render: RenderHandle::new(render_tx), + shell: Box::new(MockShellTrait::new()), + fs: Arc::new(MockFileSystemTrait::new()), + config: dummy_config_handle(), + kw_history: Arc::new(MockKwHistoryStore::new()), + kw: None, }, - user_state: UserLoreState { - bookmarked_patchsets: BookmarkedPatchsetsState { - bookmarked_patchsets: vec![], - patchset_index: 0, + } + } + + pub(super) fn spawn_real_lore_api(list: MailingList) -> LoreApiHandle { + let mut lists_store = MockMailingListsCacheStore::new(); + lists_store + .expect_load_available_lists() + .withf(|| true) + .times(1) + .returning(move || Ok(vec![list.clone()])); + let mut user_state = MockUserLoreStateStore::new(); + user_state + .expect_load_bookmarked_patchsets() + .withf(|| true) + .times(1) + .returning(|| Ok(vec![])); + user_state + .expect_load_reviewed_patchsets() + .withf(|| true) + .times(1) + .returning(|| Ok(HashMap::new())); + + let service = LoreService::new( + Arc::new(MockListsGateway::new()), + Arc::new(MockFeedGateway::new()), + Arc::new(MockPatchHtmlGateway::new()), + Arc::new(lists_store), + Arc::new(user_state), + Arc::new(MockPatchsetFetcher::new()), + Arc::new(MockPatchsetParser::new()), + Arc::new(MockFileSystemTrait::new()), + Arc::new(MockShellTrait::new()), + CacheTtl::default(), + ); + LoreApiActor::spawn(service) + } + + pub(super) fn spawn_real_render() -> RenderHandle { + RenderActor::spawn(Box::new(ShellRenderService::new(Arc::new( + MockShellTrait::new(), + )))) + } + + pub(super) fn sample_kw_ops() -> KwOpsState { + KwOpsState::new( + "title".to_string(), + "mid".to_string(), + "linux".to_string(), + serde_json::from_value(serde_json::json!({ + "path": "/kernel", + "branch": "main" + })) + .expect("json parses"), + KwReadiness { + kw_binary: KwBinaryProbe { + available: true, + version_line: Some("kw, version 0.10.0".to_string()), + check: KwVersionCheck::Meets, }, - reviewed_patchsets: HashMap::new(), + tree: TreeReadiness::Ready { + arch: Some("x86_64".to_string()), + }, + output_dir: None, + deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), + current_branch: Some("main".to_string()), + deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, }, - config_state: ConfigUiState { edit_config: None }, - config: ConfigSnapshot::from(&ConfigState::default()), - popup: None, - kw: Default::default(), - }, - services: AppServices { - lore_api: LoreApiHandle::new(lore_tx), - render: RenderHandle::new(render_tx), - shell: Box::new(MockShellTrait::new()), - fs: Arc::new(MockFileSystemTrait::new()), - config: dummy_config_handle(), - kw_history: Arc::new(MockKwHistoryStore::new()), - kw: None, - }, + ) } } + use helpers::*; + use std::sync::Arc; + + use tokio::sync::mpsc; + + use crate::{ + config::{ConfigSnapshot, ConfigState}, + infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, + input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, + kw::history::MockKwHistoryStore, + lore::domain::mailing_list::MailingList, + terminal::{ + actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, + }, + ui::actor::UiActor, + }; + + use super::*; + use crate::app::models::kw_ops::DeployStartKind; #[tokio::test(flavor = "multi_thread")] async fn input_channel_close_stops_actor_and_returns_ok() { @@ -481,46 +563,6 @@ mod tests { assert!(result.is_ok()); } - fn spawn_real_lore_api(list: MailingList) -> LoreApiHandle { - let mut lists_store = MockMailingListsCacheStore::new(); - lists_store - .expect_load_available_lists() - .withf(|| true) - .times(1) - .returning(move || Ok(vec![list.clone()])); - let mut user_state = MockUserLoreStateStore::new(); - user_state - .expect_load_bookmarked_patchsets() - .withf(|| true) - .times(1) - .returning(|| Ok(vec![])); - user_state - .expect_load_reviewed_patchsets() - .withf(|| true) - .times(1) - .returning(|| Ok(HashMap::new())); - - let service = LoreService::new( - Arc::new(MockListsGateway::new()), - Arc::new(MockFeedGateway::new()), - Arc::new(MockPatchHtmlGateway::new()), - Arc::new(lists_store), - Arc::new(user_state), - Arc::new(MockPatchsetFetcher::new()), - Arc::new(MockPatchsetParser::new()), - Arc::new(MockFileSystemTrait::new()), - Arc::new(MockShellTrait::new()), - CacheTtl::default(), - ); - LoreApiActor::spawn(service) - } - - fn spawn_real_render() -> RenderHandle { - RenderActor::spawn(Box::new(ShellRenderService::new(Arc::new( - MockShellTrait::new(), - )))) - } - /// Verifies that AppActor, LoreApiActor, and RenderActor can be wired /// together, go through a full bootstrap cycle, and all shut down cleanly /// in the documented order when the input channel closes. @@ -573,34 +615,6 @@ mod tests { render.shutdown().await; } - fn sample_kw_ops() -> KwOpsState { - KwOpsState::new( - "title".to_string(), - "mid".to_string(), - "linux".to_string(), - serde_json::from_value(serde_json::json!({ - "path": "/kernel", - "branch": "main" - })) - .expect("json parses"), - KwReadiness { - kw_binary: KwBinaryProbe { - available: true, - version_line: Some("kw, version 0.10.0".to_string()), - check: KwVersionCheck::Meets, - }, - tree: TreeReadiness::Ready { - arch: Some("x86_64".to_string()), - }, - output_dir: None, - deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), - current_branch: Some("main".to_string()), - deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), - boot_once: BootOnceState::Unknown, - }, - ) - } - #[tokio::test] async fn proceed_with_boot_once_acks_and_clears_pending() { let mut app = minimal_app(); diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 7d11db7..da2d581 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -88,6 +88,36 @@ impl DependencyService { #[cfg(test)] mod tests { + + mod helpers { + use crate::infrastructure::{ + env::MockEnvTrait, + shell::{MockShellTrait, ShellOutput}, + }; + + /// An env where every binary is present and kw reports a current + /// version, so individual tests only need to override their own case. + pub(super) fn happy_env() -> (MockEnvTrait, MockShellTrait) { + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| matches!(name, "b4" | "bat" | "git" | "kw")) + .times(3..=4) + .returning(|_| true); + let mut shell = MockShellTrait::new(); + shell + .expect_execute() + .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) + .times(1) + .returning(|_| { + Ok(ShellOutput { + stdout: b"0.10.0\n".to_vec(), + stderr: Vec::new(), + success: true, + }) + }); + (env, shell) + } + } use crate::{ config::{ConfigState, ValidatedConfigUpdate}, infrastructure::{ @@ -96,32 +126,10 @@ mod tests { }, render_prefs::PatchRenderer, }; + use helpers::*; use super::*; - /// An env where every binary is present and kw reports a current - /// version, so individual tests only need to override their own case. - fn happy_env() -> (MockEnvTrait, MockShellTrait) { - let mut env = MockEnvTrait::new(); - env.expect_which() - .withf(|name| matches!(name, "b4" | "bat" | "git" | "kw")) - .times(3..=4) - .returning(|_| true); - let mut shell = MockShellTrait::new(); - shell - .expect_execute() - .withf(|cmd| cmd.program == "kw" && cmd.args == ["--version"]) - .times(1) - .returning(|_| { - Ok(ShellOutput { - stdout: b"0.10.0\n".to_vec(), - stderr: Vec::new(), - success: true, - }) - }); - (env, shell) - } - #[test] fn missing_b4_returns_dependencies_error() { let mut env = MockEnvTrait::new(); diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index a61c7ef..3e03de2 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -95,144 +95,154 @@ impl App { #[cfg(test)] mod tests { - use std::{ - collections::HashMap, - env::VarError, - fs, - path::PathBuf, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }, - }; - - use tokio::sync::mpsc; - use crate::{ - app::{ - screens::{ - bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, - CurrentScreen, + mod helpers { + use crate::{ + app::{ + screens::{ + bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, + CurrentScreen, + }, + state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, + App, AppServices, }, - state::{AppState, ConfigUiState, LoreUiState, NavigationState, UserLoreState}, - App, AppServices, - }, - config::{ - ConfigActor, ConfigHandle, ConfigService, ConfigSnapshot, DEFAULT_CONFIG_PATH_SUFFIX, - }, - infrastructure::{ - env::MockEnvTrait, file_system::MockFileSystemTrait, file_system::OsFileSystem, - shell::MockShellTrait, - }, - kw::history::MockKwHistoryStore, - lore::{application::handle::LoreApiHandle, domain::mailing_list::MailingList}, - render::handle::RenderHandle, - }; - - use super::*; - use std::env; - use std::process; + config::{ConfigActor, ConfigHandle, ConfigService, ConfigSnapshot}, + infrastructure::{ + env::MockEnvTrait, file_system::MockFileSystemTrait, file_system::OsFileSystem, + shell::MockShellTrait, + }, + kw::history::MockKwHistoryStore, + lore::{application::handle::LoreApiHandle, domain::mailing_list::MailingList}, + render::handle::RenderHandle, + }; + use std::{ + collections::HashMap, + env::VarError, + fs, + path::PathBuf, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + }; + use tokio::sync::mpsc; - static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + use std::env; + use std::process; - fn unique_test_dir(prefix: &str) -> PathBuf { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let p = env::temp_dir().join(format!( - "patch-hub-edit-config-{prefix}-{}-{n}", - process::id() - )); - fs::create_dir_all(&p).expect("dir creates"); - p - } + pub(super) static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - fn default_env() -> (MockEnvTrait, PathBuf) { - let home = unique_test_dir("home"); - let home_s = home.to_string_lossy().into_owned(); - let mut mock = MockEnvTrait::new(); - mock.expect_var() - .withf(|key| key == "PATCH_HUB_CONFIG_PATH") - .times(1) - .returning(|_| Err(VarError::NotPresent.into())); - mock.expect_var() - .withf(move |key| key == "HOME") - .times(2) - .returning(move |_| Ok(home_s.clone())); - mock.expect_var() - .withf(|key| { - matches!( - key, - "PATCH_HUB_PAGE_SIZE" - | "PATCH_HUB_CACHE_DIR" - | "PATCH_HUB_DATA_DIR" - | "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS" - | "PATCH_HUB_PATCH_RENDERER" - ) - }) - .times(5) - .returning(|_| Err(VarError::NotPresent.into())); - (mock, home) - } + pub(super) fn unique_test_dir(prefix: &str) -> PathBuf { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let p = env::temp_dir().join(format!( + "patch-hub-edit-config-{prefix}-{}-{n}", + process::id() + )); + fs::create_dir_all(&p).expect("dir creates"); + p + } - fn app_with_kernel_trees(keys: &[&str], target: Option<&str>) -> (App, ConfigHandle, PathBuf) { - let (env, home) = default_env(); - let (mut state, repo) = - ConfigService::bootstrap_parts(&env, OsFileSystem).expect("config bootstraps"); - for key in keys { - state.kernel_trees.insert( - (*key).to_string(), - serde_json::from_value(serde_json::json!({ - "path": format!("/{key}"), - "branch": "master" - })) - .expect("json parses"), - ); + pub(super) fn default_env() -> (MockEnvTrait, PathBuf) { + let home = unique_test_dir("home"); + let home_s = home.to_string_lossy().into_owned(); + let mut mock = MockEnvTrait::new(); + mock.expect_var() + .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) + .returning(|_| Err(VarError::NotPresent.into())); + mock.expect_var() + .withf(move |key| key == "HOME") + .times(2) + .returning(move |_| Ok(home_s.clone())); + mock.expect_var() + .withf(|key| { + matches!( + key, + "PATCH_HUB_PAGE_SIZE" + | "PATCH_HUB_CACHE_DIR" + | "PATCH_HUB_DATA_DIR" + | "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS" + | "PATCH_HUB_PATCH_RENDERER" + ) + }) + .times(5) + .returning(|_| Err(VarError::NotPresent.into())); + (mock, home) } - state.target_kernel_tree = target.map(str::to_string); - let snapshot = ConfigSnapshot::from(&state); - let config = ConfigActor::spawn(state, repo); - let dummy_list = MailingList::new("test-list", "Test list"); - let (lore_tx, _lore_rx) = mpsc::channel(1); - let (render_tx, _render_rx) = mpsc::channel(1); - let app = App { - state: AppState { - navigation: NavigationState { - current_screen: CurrentScreen::EditConfig, - }, - lore: LoreUiState { - mailing_list_selection: MailingListSelectionState { - mailing_lists: vec![dummy_list.clone()], - target_list: String::new(), - possible_mailing_lists: vec![dummy_list], - highlighted_list_index: 0, + pub(super) fn app_with_kernel_trees( + keys: &[&str], + target: Option<&str>, + ) -> (App, ConfigHandle, PathBuf) { + let (env, home) = default_env(); + let (mut state, repo) = + ConfigService::bootstrap_parts(&env, OsFileSystem).expect("config bootstraps"); + for key in keys { + state.kernel_trees.insert( + (*key).to_string(), + serde_json::from_value(serde_json::json!({ + "path": format!("/{key}"), + "branch": "master" + })) + .expect("json parses"), + ); + } + state.target_kernel_tree = target.map(str::to_string); + let snapshot = ConfigSnapshot::from(&state); + let config = ConfigActor::spawn(state, repo); + + let dummy_list = MailingList::new("test-list", "Test list"); + let (lore_tx, _lore_rx) = mpsc::channel(1); + let (render_tx, _render_rx) = mpsc::channel(1); + let app = App { + state: AppState { + navigation: NavigationState { + current_screen: CurrentScreen::EditConfig, }, - latest_patchsets: None, - details: None, - }, - user_state: UserLoreState { - bookmarked_patchsets: BookmarkedPatchsetsState { - bookmarked_patchsets: vec![], - patchset_index: 0, + lore: LoreUiState { + mailing_list_selection: MailingListSelectionState { + mailing_lists: vec![dummy_list.clone()], + target_list: String::new(), + possible_mailing_lists: vec![dummy_list], + highlighted_list_index: 0, + }, + latest_patchsets: None, + details: None, + }, + user_state: UserLoreState { + bookmarked_patchsets: BookmarkedPatchsetsState { + bookmarked_patchsets: vec![], + patchset_index: 0, + }, + reviewed_patchsets: HashMap::new(), }, - reviewed_patchsets: HashMap::new(), + config_state: ConfigUiState { edit_config: None }, + config: snapshot, + popup: None, + kw: Default::default(), }, - config_state: ConfigUiState { edit_config: None }, - config: snapshot, - popup: None, - kw: Default::default(), - }, - services: AppServices { - lore_api: LoreApiHandle::new(lore_tx), - render: RenderHandle::new(render_tx), - shell: Box::new(MockShellTrait::new()), - fs: Arc::new(MockFileSystemTrait::new()), - config: config.clone(), - kw_history: Arc::new(MockKwHistoryStore::new()), - kw: None, - }, - }; - (app, config, home) + services: AppServices { + lore_api: LoreApiHandle::new(lore_tx), + render: RenderHandle::new(render_tx), + shell: Box::new(MockShellTrait::new()), + fs: Arc::new(MockFileSystemTrait::new()), + config: config.clone(), + kw_history: Arc::new(MockKwHistoryStore::new()), + kw: None, + }, + }; + (app, config, home) + } } + use helpers::*; + use std::fs; + + use crate::{ + app::{screens::CurrentScreen, App}, + config::DEFAULT_CONFIG_PATH_SUFFIX, + }; + + use super::*; #[test] fn help_documents_tree_cycle_and_save_keys() { diff --git a/src/app/flows/kw_ops.rs b/src/app/flows/kw_ops.rs index 8ab50c0..b0dcfff 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -579,41 +579,46 @@ impl App { #[cfg(test)] mod tests { - use super::*; - use crate::app::models::kw_ops::KwOpsState; - use crate::kw::models::readiness::{ - BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, - TreeReadiness, - }; - use crate::kw::models::remote::RemoteRefusal; - - fn sample_ops() -> KwOpsState { - KwOpsState::new( - "title".to_string(), - "mid".to_string(), - "linux".to_string(), - serde_json::from_value(serde_json::json!({ - "path": "/kernel", - "branch": "main" - })) - .expect("json parses"), - KwReadiness { - kw_binary: KwBinaryProbe { - available: true, - version_line: Some("kw, version 0.10.0".to_string()), - check: KwVersionCheck::Meets, - }, - tree: TreeReadiness::Ready { - arch: Some("x86_64".to_string()), + + mod helpers { + + use crate::app::models::kw_ops::KwOpsState; + use crate::kw::models::readiness::{ + BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, + TreeReadiness, + }; + use crate::kw::models::remote::RemoteRefusal; + + pub(super) fn sample_ops() -> KwOpsState { + KwOpsState::new( + "title".to_string(), + "mid".to_string(), + "linux".to_string(), + serde_json::from_value(serde_json::json!({ + "path": "/kernel", + "branch": "main" + })) + .expect("json parses"), + KwReadiness { + kw_binary: KwBinaryProbe { + available: true, + version_line: Some("kw, version 0.10.0".to_string()), + check: KwVersionCheck::Meets, + }, + tree: TreeReadiness::Ready { + arch: Some("x86_64".to_string()), + }, + output_dir: None, + deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), + current_branch: Some("main".to_string()), + deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, }, - output_dir: None, - deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), - current_branch: Some("main".to_string()), - deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), - boot_once: BootOnceState::Unknown, - }, - ) + ) + } } + use super::*; + use helpers::*; #[test] fn missing_log_is_an_empty_tail_not_a_diagnostic() { diff --git a/src/app/integration_tests/flow_errors.rs b/src/app/integration_tests/flow_errors.rs index 7c25134..22a256a 100644 --- a/src/app/integration_tests/flow_errors.rs +++ b/src/app/integration_tests/flow_errors.rs @@ -1,9 +1,6 @@ use std::ops::ControlFlow; -use crate::{ - app::{models::popup::AppPopup, screens::CurrentScreen}, - input::event::InputEvent, -}; +use crate::{app::screens::CurrentScreen, input::event::InputEvent}; use super::helpers::{ app_harness::AppHarness, @@ -83,17 +80,26 @@ async fn bookmarked_lore_failure_shows_popup_and_stays_on_bookmarks() { ], ); } +mod helpers { + + use crate::app::models::popup::AppPopup; -fn assert_info_popup_contains(popup: Option<&AppPopup>, expected_title: &str, expected: &[&str]) { - let Some(AppPopup::Info { title, body, .. }) = popup else { - panic!("expected info popup"); - }; + pub fn assert_info_popup_contains( + popup: Option<&AppPopup>, + expected_title: &str, + expected: &[&str], + ) { + let Some(AppPopup::Info { title, body, .. }) = popup else { + panic!("expected info popup"); + }; - assert_eq!(expected_title, title); - for fragment in expected { - assert!( - body.contains(fragment), - "expected popup body to contain {fragment:?}, got {body:?}" - ); + assert_eq!(expected_title, title); + for fragment in expected { + assert!( + body.contains(fragment), + "expected popup body to contain {fragment:?}, got {body:?}" + ); + } } } +pub use helpers::*; diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index 93e0201..25284e0 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -1,16 +1,6 @@ -use crate::{ - app::{ - models::popup::AppPopup, - screens::{details_actions::PatchsetDetailsState, CurrentScreen}, - }, - input::event::InputEvent, -}; +use crate::{app::screens::CurrentScreen, input::event::InputEvent}; -use super::helpers::{ - app_harness::{dummy_terminal_handle, AppHarness}, - lore::{sample_patch, sample_patchset_details}, - render::sample_rendered_preview, -}; +use super::helpers::app_harness::{dummy_terminal_handle, AppHarness}; #[tokio::test] async fn open_kw_ops_without_actor_stays_on_details() { @@ -34,27 +24,38 @@ async fn open_kw_ops_without_actor_stays_on_details() { "not attached", ); } +mod helpers { + use super::super::helpers::{ + lore::{sample_patch, sample_patchset_details}, + render::sample_rendered_preview, + }; + use crate::app::{ + models::popup::AppPopup, + screens::{details_actions::PatchsetDetailsState, CurrentScreen}, + }; -fn details_state() -> PatchsetDetailsState { - PatchsetDetailsState::build_from_rendered_preview( - sample_patch(), - sample_patchset_details(), - sample_rendered_preview(), - false, - CurrentScreen::LatestPatchsets, - ) -} + pub fn details_state() -> PatchsetDetailsState { + PatchsetDetailsState::build_from_rendered_preview( + sample_patch(), + sample_patchset_details(), + sample_rendered_preview(), + false, + CurrentScreen::LatestPatchsets, + ) + } -fn assert_info_popup(popup: Option<&AppPopup>, expected_title: &str, fragment: &str) { - let Some(AppPopup::Info { title, body, .. }) = popup else { - panic!("expected info popup"); - }; - assert_eq!(expected_title, title); - assert!( - body.contains(fragment), - "expected popup body to contain {fragment:?}, got {body:?}" - ); + pub fn assert_info_popup(popup: Option<&AppPopup>, expected_title: &str, fragment: &str) { + let Some(AppPopup::Info { title, body, .. }) = popup else { + panic!("expected info popup"); + }; + assert_eq!(expected_title, title); + assert!( + body.contains(fragment), + "expected popup body to contain {fragment:?}, got {body:?}" + ); + } } +pub use helpers::*; mod unix { use std::{ diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index 0d146df..2776ca5 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -1,48 +1,339 @@ -use std::{ - path::PathBuf, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Mutex, - }, - time::Duration, -}; - -use crate::app::handle::AppHandle; -use crate::app::App; -use crate::kw::handle::KwHandle; -use crate::ui::scene::{PopupBody, PopupScene}; -use crate::{ - app::actor::AppActor, - config::{ConfigSnapshot, ConfigState}, - infrastructure::{ - env::MockEnvTrait, - file_system::{FileSystemError, MockFileSystemTrait}, - process::FakeProcess, - shell::{MockShellTrait, ShellOutput}, - }, - input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, - kw::{actor::KwActor, history::MockKwHistoryStore, messages::StartRequest}, - lore::application::models::cache::BootstrapLoreData, - terminal::{actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi}, - ui::{actor::UiActor, scene::UiScene}, -}; - -use super::helpers::{ - app_harness::{dummy_config_handle, dummy_render_handle}, - lore::{lore_handle_with_persistence, sample_mailing_list}, -}; -use std::env; +use std::time::Duration; + +use crate::input::event::InputEvent; +use crate::ui::scene::PopupBody; + use std::fs; -use std::io; -use std::path::Path; -use std::process; -use tokio::sync::mpsc; use tokio::time; +mod helpers { + use super::super::helpers::{ + app_harness::{dummy_config_handle, dummy_render_handle}, + lore::{lore_handle_with_persistence, sample_mailing_list}, + }; + use crate::app::handle::AppHandle; + use crate::app::App; + use crate::kw::handle::KwHandle; + use crate::ui::scene::PopupScene; + use crate::{ + app::actor::AppActor, + config::{ConfigSnapshot, ConfigState}, + infrastructure::{ + env::MockEnvTrait, + file_system::{FileSystemError, MockFileSystemTrait}, + process::FakeProcess, + shell::{MockShellTrait, ShellOutput}, + }, + input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, + kw::{actor::KwActor, history::MockKwHistoryStore, messages::StartRequest}, + lore::application::models::cache::BootstrapLoreData, + terminal::{ + actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, + }, + ui::{actor::UiActor, scene::UiScene}, + }; + use std::env; + use std::fs; + use std::io; + use std::path::Path; + use std::process; + use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, + }; + use tokio::sync::mpsc; + use tokio::time; + + pub const KERNEL_TREE_PATH: &str = "/kernel"; + pub const BUILD_BRANCH: &str = "patchset-2026-08-20-15-00-00"; + + pub static LOG_DIR_SEQ: AtomicU64 = AtomicU64::new(0); + + pub fn spawn_app_actor( + app: App, + ) -> ( + Arc>>, + mpsc::Sender, + AppHandle, + ) { + let scenes = Arc::new(Mutex::new(Vec::new())); + let scenes_for_draw = Arc::clone(&scenes); + let mut session = MockTerminalSessionApi::new(); + session + .expect_draw() + .withf(|frame| matches!(frame, TerminalFrame::Main(_))) + .times(1..) + .returning(move |frame| { + if let TerminalFrame::Main(scene) = frame { + scenes_for_draw + .lock() + .expect("scenes for draw locks") + .push(*scene); + } + Ok(()) + }); + + let terminal_handle = TerminalActor::spawn(Box::new(session)); + let ui_handle = UiActor::spawn(); + let (event_tx, event_rx) = mpsc::channel::(8); + let (input_tx, _input_rx) = mpsc::channel::(1); + let input_handle = InputHandle::new(input_tx); + let handle = AppActor::spawn(app, terminal_handle, ui_handle, input_handle, event_rx); + (scenes, event_tx, handle) + } + + pub fn app_with_kw(log_dir: &Path) -> (App, KwHandle, Arc) { + let process = Arc::new(FakeProcess::new()); + let mut history = MockKwHistoryStore::new(); + history + .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "linux" && branch == "patchset-2026-08-20-15-00-00") + .times(1) + .returning(|_, _| Ok(None)); + history + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-20-15-00-00") + .times(1) + .returning(|_| Ok(())); + let kw = KwActor::spawn( + Arc::new(history), + process.clone(), + Arc::new(kw_actor_shell()), + Arc::new(kw_actor_fs()), + Arc::new(kw_actor_env()), + log_dir.to_path_buf(), + ); + let app = App::new( + apply_config(), + dummy_config_handle(), + BootstrapLoreData { + mailing_lists: vec![sample_mailing_list()], + bookmarks: vec![], + reviewed: Default::default(), + }, + Arc::new(MockFileSystemTrait::new()), + Box::new(MockShellTrait::new()), + lore_handle_with_persistence(), + dummy_render_handle(), + Arc::new(MockKwHistoryStore::new()), + Some(kw.clone()), + ) + .expect("app should build"); + (app, kw, process) + } + + pub async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str) -> bool) { + time::timeout(Duration::from_secs(5), async { + loop { + if scenes + .lock() + .expect("scenes locks") + .iter() + .any(|scene| predicate(&nav_text(scene))) + { + return; + } + time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("expected navigation text did not appear"); + } -const KERNEL_TREE_PATH: &str = "/kernel"; -const BUILD_BRANCH: &str = "patchset-2026-08-20-15-00-00"; + pub async fn wait_for_latest_popup( + scenes: &Arc>>, + predicate: impl Fn(Option<&PopupScene>) -> bool, + ) { + time::timeout(Duration::from_secs(5), async { + loop { + let matches = scenes + .lock() + .expect("scenes locks") + .last() + .is_some_and(|scene| predicate(scene.popup.as_ref())); + if matches { + return; + } + time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("expected popup state did not appear"); + } -static LOG_DIR_SEQ: AtomicU64 = AtomicU64::new(0); + pub fn scene_count(scenes: &Arc>>) -> usize { + scenes.lock().expect("scenes locks").len() + } + + pub fn nav_text(scene: &UiScene) -> String { + scene + .navigation + .mode_spans + .iter() + .map(|span| span.content.to_string()) + .collect() + } + + pub fn kw_log_dir(test_name: &str) -> PathBuf { + let n = LOG_DIR_SEQ.fetch_add(1, Ordering::SeqCst); + let dir = env::temp_dir().join(format!( + "patch-hub-app-kw-status-{}-{}-{n}", + test_name, + process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("dir creates"); + dir + } + + pub fn start_request() -> StartRequest { + StartRequest { + kernel_tree_id: "linux".to_string(), + tree: serde_json::from_value(serde_json::json!({ + "path": KERNEL_TREE_PATH, + "branch": "main" + })) + .expect("kernel tree should deserialize"), + branch: BUILD_BRANCH.to_string(), + extra_args: Vec::new(), + deploy: None, + } + } + + pub fn apply_config() -> ConfigSnapshot { + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": "main" + } + }, + "target_kernel_tree": "linux" + })) + .expect("test config should deserialize"), + ) + } + + pub fn kw_actor_shell() -> MockShellTrait { + let mut shell = MockShellTrait::new(); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/kernel", "branch", "--show-current"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "switch", + "--", + "patchset-2026-08-20-15-00-00", + ] + }) + .times(4) + .returning(|cmd| { + let stdout = match cmd.program.as_str() { + "kw" => b"kw, version 0.10.0\n".to_vec(), + _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), + _ => b"main\n".to_vec(), + }; + Ok(ShellOutput { + stdout, + stderr: Vec::new(), + success: true, + }) + }); + shell + } + + pub fn kw_actor_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.kw") + || path == std::path::Path::new("/kernel/Documentation") + || path == std::path::Path::new("/kernel/arch") + || path == std::path::Path::new("/kernel/drivers") + || path == std::path::Path::new("/kernel/fs") + || path == std::path::Path::new("/kernel/include") + || path == std::path::Path::new("/kernel/init") + || path == std::path::Path::new("/kernel/ipc") + || path == std::path::Path::new("/kernel/kernel") + || path == std::path::Path::new("/kernel/lib") + || path == std::path::Path::new("/kernel/scripts") + }) + .times(12) + .returning(|_| true); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.config") + || path == std::path::Path::new("/kernel/.kw/env.current") + || path == std::path::Path::new("/kernel/COPYING") + || path == std::path::Path::new("/kernel/CREDITS") + || path == std::path::Path::new("/kernel/Kbuild") + || path == std::path::Path::new("/kernel/Makefile") + || path == std::path::Path::new("/kernel/README") + }) + .times(7) + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/build.config") + || path == std::path::Path::new("/kernel/include/config/kernel.release") + }) + .times(1..=2) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/kernel/arch")) + .times(0..=1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); + fs + } + + pub fn kw_actor_env() -> MockEnvTrait { + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + } +} +pub use helpers::*; #[tokio::test(flavor = "multi_thread")] async fn first_frame_shows_a_job_that_started_before_attach() { @@ -256,283 +547,3 @@ async fn cancel_and_quit_requests_cancellation() { kw.shutdown().await; fs::remove_dir_all(&log_dir).expect("temp dir removes"); } - -fn spawn_app_actor( - app: App, -) -> ( - Arc>>, - mpsc::Sender, - AppHandle, -) { - let scenes = Arc::new(Mutex::new(Vec::new())); - let scenes_for_draw = Arc::clone(&scenes); - let mut session = MockTerminalSessionApi::new(); - session - .expect_draw() - .withf(|frame| matches!(frame, TerminalFrame::Main(_))) - .times(1..) - .returning(move |frame| { - if let TerminalFrame::Main(scene) = frame { - scenes_for_draw - .lock() - .expect("scenes for draw locks") - .push(*scene); - } - Ok(()) - }); - - let terminal_handle = TerminalActor::spawn(Box::new(session)); - let ui_handle = UiActor::spawn(); - let (event_tx, event_rx) = mpsc::channel::(8); - let (input_tx, _input_rx) = mpsc::channel::(1); - let input_handle = InputHandle::new(input_tx); - let handle = AppActor::spawn(app, terminal_handle, ui_handle, input_handle, event_rx); - (scenes, event_tx, handle) -} - -fn app_with_kw(log_dir: &Path) -> (App, KwHandle, Arc) { - let process = Arc::new(FakeProcess::new()); - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .withf(|tree, branch| tree == "linux" && branch == "patchset-2026-08-20-15-00-00") - .times(1) - .returning(|_, _| Ok(None)); - history - .expect_record_build() - .withf(|record| record.branch == "patchset-2026-08-20-15-00-00") - .times(1) - .returning(|_| Ok(())); - let kw = KwActor::spawn( - Arc::new(history), - process.clone(), - Arc::new(kw_actor_shell()), - Arc::new(kw_actor_fs()), - Arc::new(kw_actor_env()), - log_dir.to_path_buf(), - ); - let app = App::new( - apply_config(), - dummy_config_handle(), - BootstrapLoreData { - mailing_lists: vec![sample_mailing_list()], - bookmarks: vec![], - reviewed: Default::default(), - }, - Arc::new(MockFileSystemTrait::new()), - Box::new(MockShellTrait::new()), - lore_handle_with_persistence(), - dummy_render_handle(), - Arc::new(MockKwHistoryStore::new()), - Some(kw.clone()), - ) - .expect("app should build"); - (app, kw, process) -} - -async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str) -> bool) { - time::timeout(Duration::from_secs(5), async { - loop { - if scenes - .lock() - .expect("scenes locks") - .iter() - .any(|scene| predicate(&nav_text(scene))) - { - return; - } - time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("expected navigation text did not appear"); -} - -async fn wait_for_latest_popup( - scenes: &Arc>>, - predicate: impl Fn(Option<&PopupScene>) -> bool, -) { - time::timeout(Duration::from_secs(5), async { - loop { - let matches = scenes - .lock() - .expect("scenes locks") - .last() - .is_some_and(|scene| predicate(scene.popup.as_ref())); - if matches { - return; - } - time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("expected popup state did not appear"); -} - -fn scene_count(scenes: &Arc>>) -> usize { - scenes.lock().expect("scenes locks").len() -} - -fn nav_text(scene: &UiScene) -> String { - scene - .navigation - .mode_spans - .iter() - .map(|span| span.content.to_string()) - .collect() -} - -fn kw_log_dir(test_name: &str) -> PathBuf { - let n = LOG_DIR_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = env::temp_dir().join(format!( - "patch-hub-app-kw-status-{}-{}-{n}", - test_name, - process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - dir -} - -fn start_request() -> StartRequest { - StartRequest { - kernel_tree_id: "linux".to_string(), - tree: serde_json::from_value(serde_json::json!({ - "path": KERNEL_TREE_PATH, - "branch": "main" - })) - .expect("kernel tree should deserialize"), - branch: BUILD_BRANCH.to_string(), - extra_args: Vec::new(), - deploy: None, - } -} - -fn apply_config() -> ConfigSnapshot { - ConfigSnapshot::from( - &serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": "main" - } - }, - "target_kernel_tree": "linux" - })) - .expect("test config should deserialize"), - ) -} - -fn kw_actor_shell() -> MockShellTrait { - let mut shell = MockShellTrait::new(); - shell - .expect_execute() - .withf(|cmd| { - cmd.program == "kw" && cmd.args == ["--version"] - || cmd.program == "git" - && cmd.args - == [ - "-C", - "/kernel", - "status", - "--porcelain", - "--untracked-files=no", - ] - || cmd.program == "git" && cmd.args == ["-C", "/kernel", "branch", "--show-current"] - || cmd.program == "git" - && cmd.args - == [ - "-C", - "/kernel", - "switch", - "--", - "patchset-2026-08-20-15-00-00", - ] - }) - .times(4) - .returning(|cmd| { - let stdout = match cmd.program.as_str() { - "kw" => b"kw, version 0.10.0\n".to_vec(), - _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), - _ => b"main\n".to_vec(), - }; - Ok(ShellOutput { - stdout, - stderr: Vec::new(), - success: true, - }) - }); - shell -} - -fn kw_actor_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .withf(|path| { - path == std::path::Path::new("/kernel") - || path == std::path::Path::new("/kernel/.kw") - || path == std::path::Path::new("/kernel/Documentation") - || path == std::path::Path::new("/kernel/arch") - || path == std::path::Path::new("/kernel/drivers") - || path == std::path::Path::new("/kernel/fs") - || path == std::path::Path::new("/kernel/include") - || path == std::path::Path::new("/kernel/init") - || path == std::path::Path::new("/kernel/ipc") - || path == std::path::Path::new("/kernel/kernel") - || path == std::path::Path::new("/kernel/lib") - || path == std::path::Path::new("/kernel/scripts") - }) - .times(12) - .returning(|_| true); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/kernel/.config") - || path == std::path::Path::new("/kernel/.kw/env.current") - || path == std::path::Path::new("/kernel/COPYING") - || path == std::path::Path::new("/kernel/CREDITS") - || path == std::path::Path::new("/kernel/Kbuild") - || path == std::path::Path::new("/kernel/Makefile") - || path == std::path::Path::new("/kernel/README") - }) - .times(7) - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists() - .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) - .times(1) - .returning(|_| true); - fs.expect_read_to_string() - .withf(|path| { - path == std::path::Path::new("/kernel/.kw/build.config") - || path == std::path::Path::new("/kernel/include/config/kernel.release") - }) - .times(1..=2) - .returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_read_dir() - .withf(|path| path == std::path::Path::new("/kernel/arch")) - .times(0..=1) - .returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_create_dir_all() - .withf(|path| path.starts_with(std::env::temp_dir())) - .times(1) - .returning(|_| Ok(())); - fs -} - -fn kw_actor_env() -> MockEnvTrait { - let mut env = MockEnvTrait::new(); - env.expect_which() - .withf(|name| name == "kw") - .times(1) - .returning(|_| true); - env -} diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index e37ae9d..c6a45b3 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -1,54 +1,582 @@ use std::{ - collections::{HashMap, HashSet, VecDeque}, - path::PathBuf, + collections::HashSet, sync::{Arc, Mutex}, }; -use tokio::{spawn, sync::mpsc}; - use crate::{ - app::{ - models::popup::AppPopup, - screens::{ - details_actions::{PatchsetAction, PatchsetDetailsState}, - CurrentScreen, - }, - App, - }, - config::{ConfigSnapshot, ConfigState}, - infrastructure::{ - env::MockEnvTrait, - file_system::{FileSystemError, MockFileSystemTrait}, - process::FakeProcess, - shell::{MockShellTrait, ShellCommand, ShellOutput}, - }, - kw::{ - actor::KwActor, history::MockKwHistoryStore, messages::StartRequest, status::KwJobStatus, - }, - lore::application::{ - handle::LoreApiHandle, messages::LoreApiMessage, models::cache::BootstrapLoreData, - }, + infrastructure::{file_system::FileSystemError, process::FakeProcess, shell::MockShellTrait}, + kw::{history::MockKwHistoryStore, status::KwJobStatus}, }; -use super::helpers::{ - app_harness::{dummy_config_handle, dummy_render_handle}, - lore::{ - lore_handle_with_persistence, sample_mailing_list, sample_patch, sample_patchset_details, - }, - render::sample_rendered_preview, -}; -use std::env; +use super::helpers::lore::lore_handle_with_persistence; use std::fs; use std::io; -use std::process; use std::time::Duration; use tokio::time; +mod helpers { + use super::super::helpers::{ + app_harness::{dummy_config_handle, dummy_render_handle}, + lore::{ + lore_handle_with_persistence, sample_mailing_list, sample_patch, + sample_patchset_details, + }, + render::sample_rendered_preview, + }; + use crate::{ + app::{ + models::popup::AppPopup, + screens::{ + details_actions::{PatchsetAction, PatchsetDetailsState}, + CurrentScreen, + }, + App, + }, + config::{ConfigSnapshot, ConfigState}, + infrastructure::{ + env::MockEnvTrait, + file_system::{FileSystemError, MockFileSystemTrait}, + process::FakeProcess, + shell::{MockShellTrait, ShellCommand, ShellOutput}, + }, + kw::{actor::KwActor, history::MockKwHistoryStore, messages::StartRequest}, + lore::application::{ + handle::LoreApiHandle, messages::LoreApiMessage, models::cache::BootstrapLoreData, + }, + }; + use std::env; + use std::fs; + use std::io; + use std::process; + + use std::{ + collections::{HashMap, HashSet, VecDeque}, + path::PathBuf, + sync::{Arc, Mutex}, + }; + + use tokio::{spawn, sync::mpsc}; + + pub type ReviewedState = HashMap>; + pub type SharedReviewedState = Arc>>; + + pub const KERNEL_TREE_PATH: &str = "/kernel"; + pub const BASE_BRANCH: &str = "main"; + + pub fn app_with_apply_details(fs: MockFileSystemTrait, shell: MockShellTrait) -> App { + app_with_details( + fs, + shell, + lore_handle_with_persistence(), + apply_details_state(), + apply_config(), + history_store_allowing_writes(), + ) + } + + /// Shuts down the KwActor the app was wired with, instead of relying on + /// the test runtime aborting it at drop. + pub async fn shutdown_kw(app: &App) { + if let Some(kw) = &app.services.kw { + kw.shutdown().await; + } + } + + pub fn app_with_reviewed_reply_details(shell: MockShellTrait, lore_api: LoreApiHandle) -> App { + app_with_details( + MockFileSystemTrait::new(), + shell, + lore_api, + reviewed_reply_details_state(), + apply_config(), + MockKwHistoryStore::new(), + ) + } + + pub fn app_with_details( + fs: MockFileSystemTrait, + shell: MockShellTrait, + lore_api: LoreApiHandle, + details: PatchsetDetailsState, + config: ConfigSnapshot, + kw_history: MockKwHistoryStore, + ) -> App { + app_with_details_and_kw( + fs, + shell, + lore_api, + details, + config, + kw_history, + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + Arc::new(FakeProcess::new()), + PathBuf::from("/tmp/patch-hub-test-kw-logs"), + ) + } + + /// Variant of [`app_with_details`] whose kw actor gets its own mocks, + /// process double, and log dir, for tests that start jobs through the + /// app's kw handle. + #[expect(clippy::too_many_arguments)] + pub fn app_with_details_and_kw( + fs: MockFileSystemTrait, + shell: MockShellTrait, + lore_api: LoreApiHandle, + details: PatchsetDetailsState, + config: ConfigSnapshot, + kw_history: MockKwHistoryStore, + kw_shell: MockShellTrait, + kw_fs: MockFileSystemTrait, + kw_env: MockEnvTrait, + kw_process: Arc, + kw_log_dir: PathBuf, + ) -> App { + // Apply history is recorded through the real actor wrapping the mock + // store, mirroring production wiring. + let kw = KwActor::spawn( + Arc::new(kw_history), + kw_process, + Arc::new(kw_shell), + Arc::new(kw_fs), + Arc::new(kw_env), + kw_log_dir, + ); + let mut app = App::new( + config, + dummy_config_handle(), + BootstrapLoreData { + mailing_lists: vec![sample_mailing_list()], + bookmarks: vec![], + reviewed: Default::default(), + }, + Arc::new(fs), + Box::new(shell), + lore_api, + dummy_render_handle(), + Arc::new(MockKwHistoryStore::new()), + Some(kw), + ) + .expect("app should build"); + + app.state.navigation.current_screen = CurrentScreen::PatchsetDetails; + app.state.lore.details = Some(details); + app + } + + pub fn apply_details_state() -> PatchsetDetailsState { + let mut details = PatchsetDetailsState::build_from_rendered_preview( + sample_patch(), + sample_patchset_details(), + sample_rendered_preview(), + false, + CurrentScreen::LatestPatchsets, + ); + details.toggle_apply_action(); + details + } + + pub fn reviewed_reply_details_state() -> PatchsetDetailsState { + let mut details = PatchsetDetailsState::build_from_rendered_preview( + sample_patch(), + sample_patchset_details(), + sample_rendered_preview(), + false, + CurrentScreen::LatestPatchsets, + ); + details.toggle_reply_with_reviewed_by_action(false); + details + } + + pub fn reviewed_reply_lore_handle(saved_reviewed: SharedReviewedState) -> LoreApiHandle { + let (tx, mut rx) = mpsc::channel(8); + spawn(async move { + while let Some(message) = rx.recv().await { + match message { + LoreApiMessage::SaveBookmarks { reply, .. } => { + reply.send(Ok(())).ok(); + } + LoreApiMessage::GetGitSignature { reply, .. } => { + reply + .send(Ok(( + "Reviewer".to_string(), + "reviewer@example.com".to_string(), + ))) + .ok(); + } + LoreApiMessage::PrepareReplyCommands { reply, .. } => { + reply + .send(Ok(vec![ + ShellCommand::new("git").args(["send-email", "--annotate"]) + ])) + .ok(); + } + LoreApiMessage::SaveReviewed { reviewed, reply } => { + *saved_reviewed.lock().expect("saved reviewed locks") = Some(reviewed); + reply.send(Ok(())).ok(); + } + LoreApiMessage::Shutdown => break, + other => panic!("unexpected lore message: {}", other.name()), + } + } + }); + LoreApiHandle::new(tx) + } + + // `stay_on_applied_branch` is deliberately absent so the tests exercise the + // serde default (true) that existing config files inherit. + pub fn history_store_allowing_writes() -> MockKwHistoryStore { + let mut store = MockKwHistoryStore::new(); + store + .expect_record_apply() + .withf(|record| { + record.message_id == "http://lore.kernel.org/test-list/1234-1-foo@bar.example" + }) + .times(0..=1) + .returning(|_| Ok(())); + store + } + + pub fn apply_config() -> ConfigSnapshot { + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-" + })) + .expect("test config should deserialize"), + ) + } + + pub fn apply_config_stay_disabled() -> ConfigSnapshot { + ConfigSnapshot::from( + &serde_json::from_value::(serde_json::json!({ + "kernel_trees": { + "linux": { + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--signoff --3way", + "git_am_branch_prefix": "patchset-", + "stay_on_applied_branch": false + })) + .expect("test config should deserialize"), + ) + } + + pub fn clean_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.git") + || path == std::path::Path::new("/kernel/.git/rebase-apply") + || path == std::path::Path::new("/kernel/.git/rebase-merge") + }) + .times(0..=4) + .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.git/BISECT_LOG") + || path == std::path::Path::new("/kernel/.git/MERGE_HEAD") + }) + .times(0..=2) + .returning(|_| false); + fs + } -const KERNEL_TREE_PATH: &str = "/kernel"; -const BASE_BRANCH: &str = "main"; + pub fn shell_with_outputs( + outputs: Vec, + ) -> (MockShellTrait, Arc>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + let outputs = Arc::new(Mutex::new(VecDeque::from(outputs))); + let mut shell = MockShellTrait::new(); + let calls_for_execute = Arc::clone(&calls); + let outputs_for_execute = Arc::clone(&outputs); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "git" + && (cmd.args == ["-C", "/kernel", "status", "--porcelain"] + || cmd.args + == [ + "-C", + "/kernel", + "show-ref", + "--verify", + "--quiet", + "refs/heads/main", + ] + || cmd.args == ["-C", "/kernel", "rev-parse", "--abbrev-ref", "HEAD"] + || cmd.args == ["-C", "/kernel", "switch", "main"] + || (cmd.args.len() == 5 + && cmd.args[..4] == ["-C", "/kernel", "checkout", "-b"] + && cmd.args[4].starts_with("patchset-")) + || cmd.args + == [ + "-C", + "/kernel", + "am", + "/tmp/patchset.mbx", + "--signoff", + "--3way", + ] + || cmd.args == ["-C", "/kernel", "am", "--abort"] + || cmd.args == ["-C", "/kernel", "switch", "feature"] + || (cmd.args.len() == 5 + && cmd.args[..4] == ["-C", "/kernel", "branch", "-D"] + && cmd.args[4].starts_with("patchset-"))) + }) + .times(0..=9) + .returning(move |cmd| { + calls_for_execute + .lock() + .expect("calls for execute locks") + .push(command_parts(cmd)); + Ok(outputs_for_execute + .lock() + .expect("outputs for execute locks") + .pop_front() + .expect("test should provide one output per shell command")) + }); + (shell, calls) + } -type ReviewedState = HashMap>; -type SharedReviewedState = Arc>>; + pub fn output( + stdout: impl Into>, + stderr: impl Into>, + success: bool, + ) -> ShellOutput { + ShellOutput { + stdout: stdout.into(), + stderr: stderr.into(), + success, + } + } + + pub fn command_parts(cmd: &ShellCommand) -> Vec { + let mut parts = vec![cmd.program.clone()]; + parts.extend(cmd.args.clone()); + parts + } + + /// kw-actor mocks for tests that start jobs through the app's handle: + /// kw on PATH at the verified version, clean git state, a ready kernel + /// tree — the happy path through the actor's start probes. + pub fn kw_actor_shell() -> MockShellTrait { + let mut shell = MockShellTrait::new(); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/kernel", "branch", "--show-current"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/kernel", + "switch", + "--", + "patchset-2026-08-20-15-00-00", + ] + }) + .times(4) + .returning(|cmd| { + let stdout = match cmd.program.as_str() { + "kw" => b"kw, version 0.10.0\n".to_vec(), + _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), + _ => b"main\n".to_vec(), + }; + Ok(ShellOutput { + stdout, + stderr: Vec::new(), + success: true, + }) + }); + shell + } + + pub fn kw_actor_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/kernel") + || path == std::path::Path::new("/kernel/.kw") + || path == std::path::Path::new("/kernel/Documentation") + || path == std::path::Path::new("/kernel/arch") + || path == std::path::Path::new("/kernel/drivers") + || path == std::path::Path::new("/kernel/fs") + || path == std::path::Path::new("/kernel/include") + || path == std::path::Path::new("/kernel/init") + || path == std::path::Path::new("/kernel/ipc") + || path == std::path::Path::new("/kernel/kernel") + || path == std::path::Path::new("/kernel/lib") + || path == std::path::Path::new("/kernel/scripts") + }) + .times(12) + .returning(|_| true); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/kernel/.config") + || path == std::path::Path::new("/kernel/.kw/env.current") + || path == std::path::Path::new("/kernel/COPYING") + || path == std::path::Path::new("/kernel/CREDITS") + || path == std::path::Path::new("/kernel/Kbuild") + || path == std::path::Path::new("/kernel/Makefile") + || path == std::path::Path::new("/kernel/README") + }) + .times(7) + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/kernel/.kw/build.config") + || path == std::path::Path::new("/kernel/include/config/kernel.release") + }) + .times(1..=2) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/kernel/arch")) + .times(0..=1) + .returning(|_| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); + fs + } + + pub fn kw_actor_env() -> MockEnvTrait { + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + } + + /// A real, unique directory: FakeProcess creates the job's log file on + /// spawn, even though the fs trait is mocked. + pub fn kw_log_dir(test_name: &str) -> PathBuf { + let dir = env::temp_dir().join(format!( + "patch-hub-app-kw-logs-{}-{}", + test_name, + process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("dir creates"); + dir + } + + pub fn kw_start_request() -> StartRequest { + StartRequest { + kernel_tree_id: "linux".to_string(), + tree: serde_json::from_value(serde_json::json!({ + "path": KERNEL_TREE_PATH, + "branch": BASE_BRANCH + })) + .expect("kernel tree should deserialize"), + branch: "patchset-2026-08-20-15-00-00".to_string(), + extra_args: Vec::new(), + deploy: None, + } + } + + pub fn command(parts: &[&str]) -> Vec { + parts.iter().map(|part| part.to_string()).collect() + } + + pub fn assert_apply_action(app: &App, expected: bool) { + let details = app + .state + .lore + .details + .as_ref() + .expect("details should remain loaded"); + assert_eq!( + Some(&expected), + details.patchset_actions.get(&PatchsetAction::Apply) + ); + } + + pub fn selected_message_id(app: &App) -> String { + app.state + .lore + .details + .as_ref() + .expect("details should remain loaded") + .representative_patch + .message_id() + .href + .clone() + } + + pub fn assert_reply_action_reset(app: &App) { + let details = app + .state + .lore + .details + .as_ref() + .expect("details should remain loaded"); + assert_eq!( + Some(&false), + details + .patchset_actions + .get(&PatchsetAction::ReplyWithReviewedBy) + ); + assert_eq!(vec![false], details.patches_to_reply); + } + + pub fn assert_info_popup_contains( + popup: Option<&AppPopup>, + expected_title: &str, + expected: &[&str], + ) { + let Some(AppPopup::Info { title, body, .. }) = popup else { + panic!("expected info popup"); + }; + + assert_eq!(expected_title, title); + for fragment in expected { + assert!( + body.contains(fragment), + "expected popup body to contain {fragment:?}, got {body:?}" + ); + } + } +} +pub use helpers::*; #[tokio::test] async fn apply_success_sets_success_popup_and_resets_apply_action() { @@ -486,506 +1014,3 @@ async fn reviewed_reply_failure_does_not_record_failed_index() { assert!(saved[&message_id].is_empty()); shutdown_kw(&app).await; } - -fn app_with_apply_details(fs: MockFileSystemTrait, shell: MockShellTrait) -> App { - app_with_details( - fs, - shell, - lore_handle_with_persistence(), - apply_details_state(), - apply_config(), - history_store_allowing_writes(), - ) -} - -/// Shuts down the KwActor the app was wired with, instead of relying on -/// the test runtime aborting it at drop. -async fn shutdown_kw(app: &App) { - if let Some(kw) = &app.services.kw { - kw.shutdown().await; - } -} - -fn app_with_reviewed_reply_details(shell: MockShellTrait, lore_api: LoreApiHandle) -> App { - app_with_details( - MockFileSystemTrait::new(), - shell, - lore_api, - reviewed_reply_details_state(), - apply_config(), - MockKwHistoryStore::new(), - ) -} - -fn app_with_details( - fs: MockFileSystemTrait, - shell: MockShellTrait, - lore_api: LoreApiHandle, - details: PatchsetDetailsState, - config: ConfigSnapshot, - kw_history: MockKwHistoryStore, -) -> App { - app_with_details_and_kw( - fs, - shell, - lore_api, - details, - config, - kw_history, - MockShellTrait::new(), - MockFileSystemTrait::new(), - MockEnvTrait::new(), - Arc::new(FakeProcess::new()), - PathBuf::from("/tmp/patch-hub-test-kw-logs"), - ) -} - -/// Variant of [`app_with_details`] whose kw actor gets its own mocks, -/// process double, and log dir, for tests that start jobs through the -/// app's kw handle. -#[expect(clippy::too_many_arguments)] -fn app_with_details_and_kw( - fs: MockFileSystemTrait, - shell: MockShellTrait, - lore_api: LoreApiHandle, - details: PatchsetDetailsState, - config: ConfigSnapshot, - kw_history: MockKwHistoryStore, - kw_shell: MockShellTrait, - kw_fs: MockFileSystemTrait, - kw_env: MockEnvTrait, - kw_process: Arc, - kw_log_dir: PathBuf, -) -> App { - // Apply history is recorded through the real actor wrapping the mock - // store, mirroring production wiring. - let kw = KwActor::spawn( - Arc::new(kw_history), - kw_process, - Arc::new(kw_shell), - Arc::new(kw_fs), - Arc::new(kw_env), - kw_log_dir, - ); - let mut app = App::new( - config, - dummy_config_handle(), - BootstrapLoreData { - mailing_lists: vec![sample_mailing_list()], - bookmarks: vec![], - reviewed: Default::default(), - }, - Arc::new(fs), - Box::new(shell), - lore_api, - dummy_render_handle(), - Arc::new(MockKwHistoryStore::new()), - Some(kw), - ) - .expect("app should build"); - - app.state.navigation.current_screen = CurrentScreen::PatchsetDetails; - app.state.lore.details = Some(details); - app -} - -fn apply_details_state() -> PatchsetDetailsState { - let mut details = PatchsetDetailsState::build_from_rendered_preview( - sample_patch(), - sample_patchset_details(), - sample_rendered_preview(), - false, - CurrentScreen::LatestPatchsets, - ); - details.toggle_apply_action(); - details -} - -fn reviewed_reply_details_state() -> PatchsetDetailsState { - let mut details = PatchsetDetailsState::build_from_rendered_preview( - sample_patch(), - sample_patchset_details(), - sample_rendered_preview(), - false, - CurrentScreen::LatestPatchsets, - ); - details.toggle_reply_with_reviewed_by_action(false); - details -} - -fn reviewed_reply_lore_handle(saved_reviewed: SharedReviewedState) -> LoreApiHandle { - let (tx, mut rx) = mpsc::channel(8); - spawn(async move { - while let Some(message) = rx.recv().await { - match message { - LoreApiMessage::SaveBookmarks { reply, .. } => { - reply.send(Ok(())).ok(); - } - LoreApiMessage::GetGitSignature { reply, .. } => { - reply - .send(Ok(( - "Reviewer".to_string(), - "reviewer@example.com".to_string(), - ))) - .ok(); - } - LoreApiMessage::PrepareReplyCommands { reply, .. } => { - reply - .send(Ok(vec![ - ShellCommand::new("git").args(["send-email", "--annotate"]) - ])) - .ok(); - } - LoreApiMessage::SaveReviewed { reviewed, reply } => { - *saved_reviewed.lock().expect("saved reviewed locks") = Some(reviewed); - reply.send(Ok(())).ok(); - } - LoreApiMessage::Shutdown => break, - other => panic!("unexpected lore message: {}", other.name()), - } - } - }); - LoreApiHandle::new(tx) -} - -// `stay_on_applied_branch` is deliberately absent so the tests exercise the -// serde default (true) that existing config files inherit. -fn history_store_allowing_writes() -> MockKwHistoryStore { - let mut store = MockKwHistoryStore::new(); - store - .expect_record_apply() - .withf(|record| { - record.message_id == "http://lore.kernel.org/test-list/1234-1-foo@bar.example" - }) - .times(0..=1) - .returning(|_| Ok(())); - store -} - -fn apply_config() -> ConfigSnapshot { - ConfigSnapshot::from( - &serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-" - })) - .expect("test config should deserialize"), - ) -} - -fn apply_config_stay_disabled() -> ConfigSnapshot { - ConfigSnapshot::from( - &serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-", - "stay_on_applied_branch": false - })) - .expect("test config should deserialize"), - ) -} - -fn clean_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .withf(|path| { - path == std::path::Path::new("/kernel") - || path == std::path::Path::new("/kernel/.git") - || path == std::path::Path::new("/kernel/.git/rebase-apply") - || path == std::path::Path::new("/kernel/.git/rebase-merge") - }) - .times(0..=4) - .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/kernel/.git/BISECT_LOG") - || path == std::path::Path::new("/kernel/.git/MERGE_HEAD") - }) - .times(0..=2) - .returning(|_| false); - fs -} - -fn shell_with_outputs(outputs: Vec) -> (MockShellTrait, Arc>>>) { - let calls = Arc::new(Mutex::new(Vec::new())); - let outputs = Arc::new(Mutex::new(VecDeque::from(outputs))); - let mut shell = MockShellTrait::new(); - let calls_for_execute = Arc::clone(&calls); - let outputs_for_execute = Arc::clone(&outputs); - shell - .expect_execute() - .withf(|cmd| { - cmd.program == "git" - && (cmd.args == ["-C", "/kernel", "status", "--porcelain"] - || cmd.args - == [ - "-C", - "/kernel", - "show-ref", - "--verify", - "--quiet", - "refs/heads/main", - ] - || cmd.args == ["-C", "/kernel", "rev-parse", "--abbrev-ref", "HEAD"] - || cmd.args == ["-C", "/kernel", "switch", "main"] - || (cmd.args.len() == 5 - && cmd.args[..4] == ["-C", "/kernel", "checkout", "-b"] - && cmd.args[4].starts_with("patchset-")) - || cmd.args - == [ - "-C", - "/kernel", - "am", - "/tmp/patchset.mbx", - "--signoff", - "--3way", - ] - || cmd.args == ["-C", "/kernel", "am", "--abort"] - || cmd.args == ["-C", "/kernel", "switch", "feature"] - || (cmd.args.len() == 5 - && cmd.args[..4] == ["-C", "/kernel", "branch", "-D"] - && cmd.args[4].starts_with("patchset-"))) - }) - .times(0..=9) - .returning(move |cmd| { - calls_for_execute - .lock() - .expect("calls for execute locks") - .push(command_parts(cmd)); - Ok(outputs_for_execute - .lock() - .expect("outputs for execute locks") - .pop_front() - .expect("test should provide one output per shell command")) - }); - (shell, calls) -} - -fn output(stdout: impl Into>, stderr: impl Into>, success: bool) -> ShellOutput { - ShellOutput { - stdout: stdout.into(), - stderr: stderr.into(), - success, - } -} - -fn command_parts(cmd: &ShellCommand) -> Vec { - let mut parts = vec![cmd.program.clone()]; - parts.extend(cmd.args.clone()); - parts -} - -/// kw-actor mocks for tests that start jobs through the app's handle: -/// kw on PATH at the verified version, clean git state, a ready kernel -/// tree — the happy path through the actor's start probes. -fn kw_actor_shell() -> MockShellTrait { - let mut shell = MockShellTrait::new(); - shell - .expect_execute() - .withf(|cmd| { - cmd.program == "kw" && cmd.args == ["--version"] - || cmd.program == "git" - && cmd.args - == [ - "-C", - "/kernel", - "status", - "--porcelain", - "--untracked-files=no", - ] - || cmd.program == "git" && cmd.args == ["-C", "/kernel", "branch", "--show-current"] - || cmd.program == "git" - && cmd.args - == [ - "-C", - "/kernel", - "switch", - "--", - "patchset-2026-08-20-15-00-00", - ] - }) - .times(4) - .returning(|cmd| { - let stdout = match cmd.program.as_str() { - "kw" => b"kw, version 0.10.0\n".to_vec(), - _ if cmd.args.iter().any(|arg| arg == "status") => Vec::new(), - _ => b"main\n".to_vec(), - }; - Ok(ShellOutput { - stdout, - stderr: Vec::new(), - success: true, - }) - }); - shell -} - -fn kw_actor_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .withf(|path| { - path == std::path::Path::new("/kernel") - || path == std::path::Path::new("/kernel/.kw") - || path == std::path::Path::new("/kernel/Documentation") - || path == std::path::Path::new("/kernel/arch") - || path == std::path::Path::new("/kernel/drivers") - || path == std::path::Path::new("/kernel/fs") - || path == std::path::Path::new("/kernel/include") - || path == std::path::Path::new("/kernel/init") - || path == std::path::Path::new("/kernel/ipc") - || path == std::path::Path::new("/kernel/kernel") - || path == std::path::Path::new("/kernel/lib") - || path == std::path::Path::new("/kernel/scripts") - }) - .times(12) - .returning(|_| true); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/kernel/.config") - || path == std::path::Path::new("/kernel/.kw/env.current") - || path == std::path::Path::new("/kernel/COPYING") - || path == std::path::Path::new("/kernel/CREDITS") - || path == std::path::Path::new("/kernel/Kbuild") - || path == std::path::Path::new("/kernel/Makefile") - || path == std::path::Path::new("/kernel/README") - }) - .times(7) - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists() - .withf(|path| path == std::path::Path::new("/kernel/MAINTAINERS")) - .times(1) - .returning(|_| true); - fs.expect_read_to_string() - .withf(|path| { - path == std::path::Path::new("/kernel/.kw/build.config") - || path == std::path::Path::new("/kernel/include/config/kernel.release") - }) - .times(1..=2) - .returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_read_dir() - .withf(|path| path == std::path::Path::new("/kernel/arch")) - .times(0..=1) - .returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_create_dir_all() - .withf(|path| path.starts_with(std::env::temp_dir())) - .times(1) - .returning(|_| Ok(())); - fs -} - -fn kw_actor_env() -> MockEnvTrait { - let mut env = MockEnvTrait::new(); - env.expect_which() - .withf(|name| name == "kw") - .times(1) - .returning(|_| true); - env -} - -/// A real, unique directory: FakeProcess creates the job's log file on -/// spawn, even though the fs trait is mocked. -fn kw_log_dir(test_name: &str) -> PathBuf { - let dir = env::temp_dir().join(format!( - "patch-hub-app-kw-logs-{}-{}", - test_name, - process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - dir -} - -fn kw_start_request() -> StartRequest { - StartRequest { - kernel_tree_id: "linux".to_string(), - tree: serde_json::from_value(serde_json::json!({ - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - })) - .expect("kernel tree should deserialize"), - branch: "patchset-2026-08-20-15-00-00".to_string(), - extra_args: Vec::new(), - deploy: None, - } -} - -fn command(parts: &[&str]) -> Vec { - parts.iter().map(|part| part.to_string()).collect() -} - -fn assert_apply_action(app: &App, expected: bool) { - let details = app - .state - .lore - .details - .as_ref() - .expect("details should remain loaded"); - assert_eq!( - Some(&expected), - details.patchset_actions.get(&PatchsetAction::Apply) - ); -} - -fn selected_message_id(app: &App) -> String { - app.state - .lore - .details - .as_ref() - .expect("details should remain loaded") - .representative_patch - .message_id() - .href - .clone() -} - -fn assert_reply_action_reset(app: &App) { - let details = app - .state - .lore - .details - .as_ref() - .expect("details should remain loaded"); - assert_eq!( - Some(&false), - details - .patchset_actions - .get(&PatchsetAction::ReplyWithReviewedBy) - ); - assert_eq!(vec![false], details.patches_to_reply); -} - -fn assert_info_popup_contains(popup: Option<&AppPopup>, expected_title: &str, expected: &[&str]) { - let Some(AppPopup::Info { title, body, .. }) = popup else { - panic!("expected info popup"); - }; - - assert_eq!(expected_title, title); - for fragment in expected { - assert!( - body.contains(fragment), - "expected popup body to contain {fragment:?}, got {body:?}" - ); - } -} diff --git a/src/app/mod.rs b/src/app/mod.rs index d1b6094..4da95d2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -604,15 +604,15 @@ impl App { #[cfg(test)] mod tests { - use std::collections::{HashMap, HashSet}; - use serde_xml_rs::from_str; + mod helpers { + use super::super::*; + use serde_xml_rs::from_str; + use std::collections::{HashMap, HashSet}; - use super::*; - - fn test_patch() -> Patch { - from_str( - r#" + pub(super) fn test_patch() -> Patch { + from_str( + r#" Foo Bar @@ -625,33 +625,38 @@ mod tests { "#, - ) - .expect("test patch XML should deserialize") - } + ) + .expect("test patch XML should deserialize") + } - fn details_state() -> PatchsetDetailsState { - PatchsetDetailsState { - representative_patch: test_patch(), - raw_patches: vec!["raw patch 0".to_string(), "raw patch 1".to_string()], - patches_preview: vec!["preview 0".to_string(), "preview 1".to_string()], - has_cover_letter: false, - patches_to_reply: vec![false, true], - patchset_path: "/tmp/patchset.mbx".to_string(), - preview_index: 0, - preview_scroll_offset: 0, - preview_pan: 0, - preview_fullscreen: false, - patchset_actions: HashMap::from([ - (PatchsetAction::Bookmark, false), - (PatchsetAction::ReplyWithReviewedBy, true), - (PatchsetAction::Apply, true), - ]), - reviewed_by: vec![HashSet::new(), HashSet::new()], - tested_by: vec![HashSet::new(), HashSet::new()], - acked_by: vec![HashSet::new(), HashSet::new()], - last_screen: CurrentScreen::LatestPatchsets, + pub(super) fn details_state() -> PatchsetDetailsState { + PatchsetDetailsState { + representative_patch: test_patch(), + raw_patches: vec!["raw patch 0".to_string(), "raw patch 1".to_string()], + patches_preview: vec!["preview 0".to_string(), "preview 1".to_string()], + has_cover_letter: false, + patches_to_reply: vec![false, true], + patchset_path: "/tmp/patchset.mbx".to_string(), + preview_index: 0, + preview_scroll_offset: 0, + preview_pan: 0, + preview_fullscreen: false, + patchset_actions: HashMap::from([ + (PatchsetAction::Bookmark, false), + (PatchsetAction::ReplyWithReviewedBy, true), + (PatchsetAction::Apply, true), + ]), + reviewed_by: vec![HashSet::new(), HashSet::new()], + tested_by: vec![HashSet::new(), HashSet::new()], + acked_by: vec![HashSet::new(), HashSet::new()], + last_screen: CurrentScreen::LatestPatchsets, + } } } + use helpers::*; + use std::collections::HashSet; + + use super::*; #[test] fn patchset_action_selected_reads_action_map() { diff --git a/src/app/screens/details_actions.rs b/src/app/screens/details_actions.rs index e1b8e56..a69fe77 100644 --- a/src/app/screens/details_actions.rs +++ b/src/app/screens/details_actions.rs @@ -225,15 +225,15 @@ impl PatchsetDetailsState { #[cfg(test)] mod tests { - use std::collections::{HashMap, HashSet}; - use serde_xml_rs::from_str; + mod helpers { + use super::super::*; + use serde_xml_rs::from_str; + use std::collections::{HashMap, HashSet}; - use super::*; - - fn test_patch() -> Patch { - from_str( - r#" + pub(super) fn test_patch() -> Patch { + from_str( + r#" Foo Bar @@ -246,33 +246,37 @@ mod tests { "#, - ) - .expect("test patch XML should deserialize") - } + ) + .expect("test patch XML should deserialize") + } - fn details_state_with_preview(preview: &str) -> PatchsetDetailsState { - PatchsetDetailsState { - representative_patch: test_patch(), - raw_patches: vec!["raw patch".to_string()], - patches_preview: vec![preview.to_string()], - has_cover_letter: false, - patches_to_reply: vec![false], - patchset_path: "/tmp/patchset.mbx".to_string(), - preview_index: 0, - preview_scroll_offset: 0, - preview_pan: 0, - preview_fullscreen: false, - patchset_actions: HashMap::from([ - (PatchsetAction::Bookmark, false), - (PatchsetAction::ReplyWithReviewedBy, false), - (PatchsetAction::Apply, false), - ]), - reviewed_by: vec![HashSet::new()], - tested_by: vec![HashSet::new()], - acked_by: vec![HashSet::new()], - last_screen: CurrentScreen::LatestPatchsets, + pub(super) fn details_state_with_preview(preview: &str) -> PatchsetDetailsState { + PatchsetDetailsState { + representative_patch: test_patch(), + raw_patches: vec!["raw patch".to_string()], + patches_preview: vec![preview.to_string()], + has_cover_letter: false, + patches_to_reply: vec![false], + patchset_path: "/tmp/patchset.mbx".to_string(), + preview_index: 0, + preview_scroll_offset: 0, + preview_pan: 0, + preview_fullscreen: false, + patchset_actions: HashMap::from([ + (PatchsetAction::Bookmark, false), + (PatchsetAction::ReplyWithReviewedBy, false), + (PatchsetAction::Apply, false), + ]), + reviewed_by: vec![HashSet::new()], + tested_by: vec![HashSet::new()], + acked_by: vec![HashSet::new()], + last_screen: CurrentScreen::LatestPatchsets, + } } } + use helpers::*; + + use super::*; #[test] fn rendered_height_accounts_for_rendered_text_projection() { diff --git a/src/app/screens/edit_config.rs b/src/app/screens/edit_config.rs index 1367694..95cc68b 100644 --- a/src/app/screens/edit_config.rs +++ b/src/app/screens/edit_config.rs @@ -315,30 +315,36 @@ impl Display for EditableConfig { #[cfg(test)] mod tests { - use super::*; - use crate::config::ConfigState; - fn snapshot_with_trees(keys: &[&str], target: Option<&str>) -> ConfigSnapshot { - let mut state = ConfigState::default(); - for key in keys { - state.kernel_trees.insert( - (*key).to_string(), - serde_json::from_value(serde_json::json!({ - "path": format!("/{key}"), - "branch": "master" - })) - .expect("json parses"), - ); + mod helpers { + use super::super::*; + use crate::config::ConfigState; + + pub(super) fn snapshot_with_trees(keys: &[&str], target: Option<&str>) -> ConfigSnapshot { + let mut state = ConfigState::default(); + for key in keys { + state.kernel_trees.insert( + (*key).to_string(), + serde_json::from_value(serde_json::json!({ + "path": format!("/{key}"), + "branch": "master" + })) + .expect("json parses"), + ); + } + state.target_kernel_tree = target.map(str::to_string); + ConfigSnapshot::from(&state) } - state.target_kernel_tree = target.map(str::to_string); - ConfigSnapshot::from(&state) - } - fn tree_row(edit: &mut EditConfigState) { - while edit.highlighted() != 11 { - edit.highlight_next(); + pub(super) fn tree_row(edit: &mut EditConfigState) { + while edit.highlighted() != 11 { + edit.highlight_next(); + } } } + use super::*; + use crate::config::ConfigState; + use helpers::*; #[test] fn draft_includes_deploy_knobs_with_compiled_in_defaults() { diff --git a/src/app/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index 263fe45..16464f0 100644 --- a/src/app/screens/kw_ops.rs +++ b/src/app/screens/kw_ops.rs @@ -107,39 +107,45 @@ impl KwOpsState { #[cfg(test)] mod tests { - use super::*; - use crate::app::models::kw_ops::DeployStartKind; - use crate::kw::models::readiness::{ - BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, - TreeReadiness, - }; - use crate::kw::models::remote::RemoteRefusal; - fn sample_tree() -> KernelTree { - serde_json::from_value(serde_json::json!({ - "path": "/kernel", - "branch": "main" - })) - .expect("kernel tree should deserialize") - } + mod helpers { + use super::super::*; + + use crate::kw::models::readiness::{ + BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, + TreeReadiness, + }; + use crate::kw::models::remote::RemoteRefusal; - fn readiness(branch: Option<&str>) -> KwReadiness { - KwReadiness { - kw_binary: KwBinaryProbe { - available: true, - version_line: Some("kw, version 0.10.0".to_string()), - check: KwVersionCheck::Meets, - }, - tree: TreeReadiness::Ready { - arch: Some("x86_64".to_string()), - }, - output_dir: None, - deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), - current_branch: branch.map(str::to_string), - deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), - boot_once: BootOnceState::Unknown, + pub(super) fn sample_tree() -> KernelTree { + serde_json::from_value(serde_json::json!({ + "path": "/kernel", + "branch": "main" + })) + .expect("kernel tree should deserialize") + } + + pub(super) fn readiness(branch: Option<&str>) -> KwReadiness { + KwReadiness { + kw_binary: KwBinaryProbe { + available: true, + version_line: Some("kw, version 0.10.0".to_string()), + check: KwVersionCheck::Meets, + }, + tree: TreeReadiness::Ready { + arch: Some("x86_64".to_string()), + }, + output_dir: None, + deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), + current_branch: branch.map(str::to_string), + deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, + } } } + use super::*; + use crate::app::models::kw_ops::DeployStartKind; + use helpers::*; #[test] fn prefills_branch_from_readiness_not_from_tree_config() { diff --git a/src/app/screens/latest.rs b/src/app/screens/latest.rs index b624911..0470e38 100644 --- a/src/app/screens/latest.rs +++ b/src/app/screens/latest.rs @@ -110,68 +110,72 @@ impl LatestPatchsetsState { #[cfg(test)] mod tests { - use super::*; - use std::sync::Arc; - - use crate::{ - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - lore::{ - application::{actor::LoreApiActor, models::cache::CacheTtl, service::LoreService}, - infrastructure::{ - http_lore_client::{ - LoreHttpError, MockFeedGateway, MockListsGateway, MockPatchHtmlGateway, + + mod helpers { + use super::super::*; + use crate::{ + infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, + lore::{ + application::{actor::LoreApiActor, models::cache::CacheTtl, service::LoreService}, + infrastructure::{ + http_lore_client::{MockFeedGateway, MockListsGateway, MockPatchHtmlGateway}, + patchset_fetcher::MockPatchsetFetcher, + patchset_parser::MockPatchsetParser, + persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, }, - patchset_fetcher::MockPatchsetFetcher, - patchset_parser::MockPatchsetParser, - persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, }, - }, - }; - - fn make_patch(msg_id: &str) -> Patch { - serde_json::from_value(serde_json::json!({ - "title": "test patch", - "author": { "name": "Test", "email": "test@test.com" }, - "link": { "@href": msg_id }, - "updated": "2023-01-01" - })) - .expect("json parses") - } + }; + use std::sync::Arc; + + pub(super) fn make_patch(msg_id: &str) -> Patch { + serde_json::from_value(serde_json::json!({ + "title": "test patch", + "author": { "name": "Test", "email": "test@test.com" }, + "link": { "@href": msg_id }, + "updated": "2023-01-01" + })) + .expect("json parses") + } - fn make_handle(feed_gateway: MockFeedGateway) -> LoreApiHandle { - let service = LoreService::new( - Arc::new(MockListsGateway::new()), - Arc::new(feed_gateway), - Arc::new(MockPatchHtmlGateway::new()), - Arc::new(MockMailingListsCacheStore::new()), - Arc::new(MockUserLoreStateStore::new()), - Arc::new(MockPatchsetFetcher::new()), - Arc::new(MockPatchsetParser::new()), - Arc::new(MockFileSystemTrait::new()), - Arc::new(MockShellTrait::new()), - CacheTtl::default(), - ); - LoreApiActor::spawn(service) - } + pub(super) fn make_handle(feed_gateway: MockFeedGateway) -> LoreApiHandle { + let service = LoreService::new( + Arc::new(MockListsGateway::new()), + Arc::new(feed_gateway), + Arc::new(MockPatchHtmlGateway::new()), + Arc::new(MockMailingListsCacheStore::new()), + Arc::new(MockUserLoreStateStore::new()), + Arc::new(MockPatchsetFetcher::new()), + Arc::new(MockPatchsetParser::new()), + Arc::new(MockFileSystemTrait::new()), + Arc::new(MockShellTrait::new()), + CacheTtl::default(), + ); + LoreApiActor::spawn(service) + } - fn patch_feed_response() -> String { - r#" - - + pub(super) fn patch_feed_response() -> String { + r#" + + test patch Testtest@test.com 2023-01-01 - - + + test patch 2 Testtest@test.com 2023-01-01 - -"# - .to_string() + + "# + .to_string() + } } + use super::*; + use helpers::*; + + use crate::lore::infrastructure::http_lore_client::{LoreHttpError, MockFeedGateway}; #[tokio::test] async fn test_fetch_current_page_success() { diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 45fbcdb..44c3e57 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -664,56 +664,122 @@ impl From<&AppPopup> for PopupViewModel { #[cfg(test)] mod tests { - use std::{collections::HashMap, path::PathBuf}; + + mod helpers { + use super::super::*; + use crate::{ + app::models::kw_ops::KwOpsState, + kw::models::readiness::{KwReadiness, KwVersionCheck}, + }; + use crate::{ + app::{ + screens::{ + bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, + }, + state::{ + AppState, ConfigUiState, KwUiState, LoreUiState, NavigationState, UserLoreState, + }, + }, + config::{ConfigSnapshot, ConfigState}, + kw::status::{KwJobKind, KwJobStatus, KwStatusSnapshot}, + lore::domain::mailing_list::MailingList, + }; + use std::{collections::HashMap, path::PathBuf}; + + pub(super) fn app_state_with_kw(status: Option) -> AppState { + let dummy_list = MailingList::new("test-list", "Test list"); + AppState { + navigation: NavigationState { + current_screen: CurrentScreen::MailingListSelection, + }, + lore: LoreUiState { + mailing_list_selection: MailingListSelectionState { + mailing_lists: vec![dummy_list.clone()], + target_list: String::new(), + possible_mailing_lists: vec![dummy_list], + highlighted_list_index: 0, + }, + latest_patchsets: None, + details: None, + }, + user_state: UserLoreState { + bookmarked_patchsets: BookmarkedPatchsetsState { + bookmarked_patchsets: vec![], + patchset_index: 0, + }, + reviewed_patchsets: HashMap::new(), + }, + config_state: ConfigUiState { edit_config: None }, + config: ConfigSnapshot::from(&ConfigState::default()), + popup: None, + kw: KwUiState { status, ops: None }, + } + } + + pub(super) fn sample_kw_ops(branch: Option<&str>) -> KwOpsState { + KwOpsState::new( + "[PATCH] test".to_string(), + "http://lore.example/123".to_string(), + "linux".to_string(), + serde_json::from_value(serde_json::json!({ + "path": "/kernel", + "branch": "main" + })) + .expect("json parses"), + KwReadiness { + kw_binary: KwBinaryProbe { + available: true, + version_line: Some("kw, version 0.10.0".to_string()), + check: KwVersionCheck::Meets, + }, + tree: TreeReadiness::Ready { + arch: Some("x86_64".to_string()), + }, + output_dir: None, + deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), + current_branch: branch.map(str::to_string), + deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, + }, + ) + } + + pub(super) fn sample_remote() -> KwRemote { + KwRemote { + name: "dut".to_string(), + hostname: "box".to_string(), + port: 22, + user: Some("root".to_string()), + } + } + + pub(super) fn succeeded_deploy(warnings: &[&str]) -> KwStatusSnapshot { + KwStatusSnapshot { + job: KwJobStatus::Succeeded { + kind: KwJobKind::Deploy, + kernel_tree_id: "mainline".to_string(), + branch: "patchset-x".to_string(), + log_path: PathBuf::from("/tmp/deploy.log"), + warnings: warnings.iter().map(|w| w.to_string()).collect(), + }, + restore_branch: None, + } + } + } + use helpers::*; + use std::path::PathBuf; use crate::{ app::{models::kw_ops::KwOpsState, screens::edit_config::EditConfigState}, kw::models::readiness::{KwReadiness, KwVersionCheck}, }; use crate::{ - app::{ - screens::{bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState}, - state::{ - AppState, ConfigUiState, KwUiState, LoreUiState, NavigationState, UserLoreState, - }, - }, config::{ConfigSnapshot, ConfigState}, kw::status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, - lore::domain::mailing_list::MailingList, }; use super::*; - fn app_state_with_kw(status: Option) -> AppState { - let dummy_list = MailingList::new("test-list", "Test list"); - AppState { - navigation: NavigationState { - current_screen: CurrentScreen::MailingListSelection, - }, - lore: LoreUiState { - mailing_list_selection: MailingListSelectionState { - mailing_lists: vec![dummy_list.clone()], - target_list: String::new(), - possible_mailing_lists: vec![dummy_list], - highlighted_list_index: 0, - }, - latest_patchsets: None, - details: None, - }, - user_state: UserLoreState { - bookmarked_patchsets: BookmarkedPatchsetsState { - bookmarked_patchsets: vec![], - patchset_index: 0, - }, - reviewed_patchsets: HashMap::new(), - }, - config_state: ConfigUiState { edit_config: None }, - config: ConfigSnapshot::from(&ConfigState::default()), - popup: None, - kw: KwUiState { status, ops: None }, - } - } - #[test] fn running_job_projects_a_global_indicator() { let vm = AppViewModel::from(&app_state_with_kw(Some(KwStatusSnapshot { @@ -844,34 +910,6 @@ mod tests { assert_eq!("unavailable (no remotes configured)", vm.build_deploy_label); } - fn sample_kw_ops(branch: Option<&str>) -> KwOpsState { - KwOpsState::new( - "[PATCH] test".to_string(), - "http://lore.example/123".to_string(), - "linux".to_string(), - serde_json::from_value(serde_json::json!({ - "path": "/kernel", - "branch": "main" - })) - .expect("json parses"), - KwReadiness { - kw_binary: KwBinaryProbe { - available: true, - version_line: Some("kw, version 0.10.0".to_string()), - check: KwVersionCheck::Meets, - }, - tree: TreeReadiness::Ready { - arch: Some("x86_64".to_string()), - }, - output_dir: None, - deploy_alone: Err(DeployAloneRefusal::NoBuildRecord), - current_branch: branch.map(str::to_string), - deploy_remote: Err(RemoteRefusal::NoRemotesConfigured), - boot_once: BootOnceState::Unknown, - }, - ) - } - #[test] fn cleared_readable_branch_does_not_claim_detached_head() { let mut state = app_state_with_kw(None); @@ -945,15 +983,6 @@ mod tests { ); } - fn sample_remote() -> KwRemote { - KwRemote { - name: "dut".to_string(), - hostname: "box".to_string(), - port: 22, - user: Some("root".to_string()), - } - } - #[test] fn deploy_alone_refusal_disables_deploy_but_not_build_then_deploy() { let mut state = app_state_with_kw(None); @@ -1113,19 +1142,6 @@ mod tests { assert_eq!(None, vm.warnings); } - fn succeeded_deploy(warnings: &[&str]) -> KwStatusSnapshot { - KwStatusSnapshot { - job: KwJobStatus::Succeeded { - kind: KwJobKind::Deploy, - kernel_tree_id: "mainline".to_string(), - branch: "patchset-x".to_string(), - log_path: PathBuf::from("/tmp/deploy.log"), - warnings: warnings.iter().map(|w| w.to_string()).collect(), - }, - restore_branch: None, - } - } - #[test] fn deploy_with_warnings_projects_count_and_joined_warnings() { let mut state = app_state_with_kw(Some(succeeded_deploy(&[ diff --git a/src/config/actor.rs b/src/config/actor.rs index 68d9296..fcd5e3d 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -110,67 +110,70 @@ where #[cfg(test)] mod tests { - use std::{ - env::VarError, - fs, - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, - }; - - use crate::{ - config::{ - service::ConfigService, ConfigError, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX, - }, - infrastructure::{env::MockEnvTrait, file_system::OsFileSystem}, - }; - - use super::*; - use std::env; - use std::process; - - static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - - fn unique_test_dir(prefix: &str) -> PathBuf { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{n}", process::id())); - fs::create_dir_all(&p).expect("dir creates"); - p - } - fn default_env() -> (MockEnvTrait, PathBuf) { - let home = unique_test_dir("actor-home"); - let home_s = home.to_string_lossy().into_owned(); - let mut mock = MockEnvTrait::new(); - mock.expect_var() - .withf(|key| key == "PATCH_HUB_CONFIG_PATH") - .times(1) - .returning(|_| Err(VarError::NotPresent.into())); - mock.expect_var() - .withf(move |key| key == "HOME") - .times(2) - .returning(move |_| Ok(home_s.clone())); - mock.expect_var() - .withf(|key| { - matches!( - key, - "PATCH_HUB_PAGE_SIZE" - | "PATCH_HUB_CACHE_DIR" - | "PATCH_HUB_DATA_DIR" - | "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS" - | "PATCH_HUB_PATCH_RENDERER" - ) - }) - .times(5) - .returning(|_| Err(VarError::NotPresent.into())); - (mock, home) - } + mod helpers { + use super::super::*; + use crate::{ + config::service::ConfigService, + infrastructure::{env::MockEnvTrait, file_system::OsFileSystem}, + }; + use std::env; + use std::process; + use std::{ + env::VarError, + fs, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + + pub(super) static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + + pub(super) fn unique_test_dir(prefix: &str) -> PathBuf { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{n}", process::id())); + fs::create_dir_all(&p).expect("dir creates"); + p + } - fn spawn_test_actor() -> (ConfigHandle, PathBuf) { - let (env, home) = default_env(); - let (state, repo) = - ConfigService::bootstrap_parts(&env, OsFileSystem).expect("config bootstraps"); - (ConfigActor::spawn(state, repo), home) + pub(super) fn default_env() -> (MockEnvTrait, PathBuf) { + let home = unique_test_dir("actor-home"); + let home_s = home.to_string_lossy().into_owned(); + let mut mock = MockEnvTrait::new(); + mock.expect_var() + .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(1) + .returning(|_| Err(VarError::NotPresent.into())); + mock.expect_var() + .withf(move |key| key == "HOME") + .times(2) + .returning(move |_| Ok(home_s.clone())); + mock.expect_var() + .withf(|key| { + matches!( + key, + "PATCH_HUB_PAGE_SIZE" + | "PATCH_HUB_CACHE_DIR" + | "PATCH_HUB_DATA_DIR" + | "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS" + | "PATCH_HUB_PATCH_RENDERER" + ) + }) + .times(5) + .returning(|_| Err(VarError::NotPresent.into())); + (mock, home) + } + + pub(super) fn spawn_test_actor() -> (ConfigHandle, PathBuf) { + let (env, home) = default_env(); + let (state, repo) = + ConfigService::bootstrap_parts(&env, OsFileSystem).expect("config bootstraps"); + (ConfigActor::spawn(state, repo), home) + } } + use helpers::*; + use std::fs; + + use crate::config::{ConfigError, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX}; #[tokio::test] async fn get_snapshot_returns_actor_state() { diff --git a/src/config/tests.rs b/src/config/tests.rs index 042dd9d..bb0ab44 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1,141 +1,149 @@ use serde_json::json; -use std::{ - collections::HashSet, - env::VarError, - fs, - path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, -}; +use std::{collections::HashSet, env::VarError, fs}; use crate::config::actor::ConfigActor; use crate::config::repository::{ConfigRepository, JsonConfigRepository}; use crate::config::service::ConfigService; use crate::config::state::ConfigState; use crate::config::{ - ConfigError, ConfigSnapshot, ConfigUpdateDraft, KernelTree, ValidatedConfigUpdate, - DEFAULT_CONFIG_PATH_SUFFIX, -}; -use crate::infrastructure::{ - env::{EnvTrait, MockEnvTrait}, - file_system::OsFileSystem, + ConfigError, ConfigUpdateDraft, ValidatedConfigUpdate, DEFAULT_CONFIG_PATH_SUFFIX, }; -use std::env; -use std::process; - -static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - -fn os_fs() -> OsFileSystem { - OsFileSystem -} +use crate::infrastructure::env::MockEnvTrait; +mod helpers { + + use crate::config::service::ConfigService; + use crate::config::state::ConfigState; + use crate::config::{ConfigSnapshot, KernelTree}; + use crate::infrastructure::{ + env::{EnvTrait, MockEnvTrait}, + file_system::OsFileSystem, + }; + use serde_json::json; + use std::env; + use std::process; + use std::{ + env::VarError, + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + }; + + pub static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + + pub fn os_fs() -> OsFileSystem { + OsFileSystem + } -fn sample_kernel_tree(path: &str, branch: &str) -> KernelTree { - serde_json::from_value(json!({ - "path": path, - "branch": branch - })) - .expect("json parses") -} + pub fn sample_kernel_tree(path: &str, branch: &str) -> KernelTree { + serde_json::from_value(json!({ + "path": path, + "branch": branch + })) + .expect("json parses") + } -fn state_with_trees(env: &dyn EnvTrait) -> ConfigState { - let mut state = ConfigState::new_with_defaults(env); - state.kernel_trees.insert( - "linux".into(), - sample_kernel_tree("/home/user/linux", "master"), - ); - state.kernel_trees.insert( - "amd-gfx".into(), - sample_kernel_tree("/home/user/amd-gfx", "amd-staging-drm-next"), - ); - state -} + pub fn state_with_trees(env: &dyn EnvTrait) -> ConfigState { + let mut state = ConfigState::new_with_defaults(env); + state.kernel_trees.insert( + "linux".into(), + sample_kernel_tree("/home/user/linux", "master"), + ); + state.kernel_trees.insert( + "amd-gfx".into(), + sample_kernel_tree("/home/user/amd-gfx", "amd-staging-drm-next"), + ); + state + } -fn unique_test_dir(prefix: &str) -> PathBuf { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{}", process::id(), n)); - fs::create_dir_all(&p).expect("dir creates"); - p -} + pub fn unique_test_dir(prefix: &str) -> PathBuf { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let p = env::temp_dir().join(format!("patch-hub-{prefix}-{}-{}", process::id(), n)); + fs::create_dir_all(&p).expect("dir creates"); + p + } -fn bootstrap_snapshot(env: &dyn EnvTrait) -> ConfigSnapshot { - ConfigSnapshot::from( - &ConfigService::bootstrap_parts(env, os_fs()) - .expect("config bootstraps") - .0, - ) -} + pub fn bootstrap_snapshot(env: &dyn EnvTrait) -> ConfigSnapshot { + ConfigSnapshot::from( + &ConfigService::bootstrap_parts(env, os_fs()) + .expect("config bootstraps") + .0, + ) + } -/// Writable `HOME` and mock env: no `PATCH_HUB_CONFIG_PATH` (uses `HOME/.config/...`). -fn default_env() -> (MockEnvTrait, PathBuf) { - let home = unique_test_dir("home"); - let home_s = home.to_string_lossy().into_owned(); - let mut mock = MockEnvTrait::new(); - mock.expect_var() - .withf(|key| key == "PATCH_HUB_CONFIG_PATH") - .times(0..=1) - .returning(|_| Err(VarError::NotPresent.into())); - mock.expect_var() - .withf(move |key| key == "HOME") - .times(1..=2) - .returning(move |_| Ok(home_s.clone())); - mock.expect_var() - .withf(|key| { - matches!( - key, - "PATCH_HUB_PAGE_SIZE" - | "PATCH_HUB_CACHE_DIR" - | "PATCH_HUB_DATA_DIR" - | "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS" - | "PATCH_HUB_PATCH_RENDERER" - ) - }) - .times(0..=5) - .returning(|_| Err(VarError::NotPresent.into())); - (mock, home) -} + /// Writable `HOME` and mock env: no `PATCH_HUB_CONFIG_PATH` (uses `HOME/.config/...`). + pub fn default_env() -> (MockEnvTrait, PathBuf) { + let home = unique_test_dir("home"); + let home_s = home.to_string_lossy().into_owned(); + let mut mock = MockEnvTrait::new(); + mock.expect_var() + .withf(|key| key == "PATCH_HUB_CONFIG_PATH") + .times(0..=1) + .returning(|_| Err(VarError::NotPresent.into())); + mock.expect_var() + .withf(move |key| key == "HOME") + .times(1..=2) + .returning(move |_| Ok(home_s.clone())); + mock.expect_var() + .withf(|key| { + matches!( + key, + "PATCH_HUB_PAGE_SIZE" + | "PATCH_HUB_CACHE_DIR" + | "PATCH_HUB_DATA_DIR" + | "PATCH_HUB_GIT_SEND_EMAIL_OPTIONS" + | "PATCH_HUB_PATCH_RENDERER" + ) + }) + .times(0..=5) + .returning(|_| Err(VarError::NotPresent.into())); + (mock, home) + } -/// Fully-populated config file content, with paths under `root` so -/// `ensure_directories` stays inside a writable temp tree. After bootstrap, `normalize_derived_paths` -/// overwrites patchset/data paths from `cache_dir` and `data_dir` only (explicit per-field paths -/// in JSON are not preserved). -fn config_fixture_json(root: &Path) -> String { - let patchsets_cache_dir = root.join("cachedir").join("path"); - let bookmarked = root.join("bookmarked").join("patchsets.json"); - let mailing = root.join("mailing").join("lists.json"); - let reviewed = root.join("reviewed").join("patchsets.json"); - let logs = root.join("logs"); - let cache_dir = root.join("cache_dir"); - let data_dir = root.join("data_dir"); - - let v = json!({ - "page_size": 1234, - "patchsets_cache_dir": patchsets_cache_dir.to_str(), - "bookmarked_patchsets_path": bookmarked.to_str(), - "mailing_lists_path": mailing.to_str(), - "reviewed_patchsets_path": reviewed.to_str(), - "logs_path": logs.to_str(), - "git_send_email_options": "--long-option value -s -h -o -r -t", - "cache_dir": cache_dir.to_str(), - "data_dir": data_dir.to_str(), - "patch_renderer": "default", - "cover_renderer": "default", - "max_log_age": 42, - "kernel_trees": { - "linux": { - "path": "/home/user/linux", - "branch": "master" - }, - "amd-gfx": { - "path": "/home/user/amd-gfx", - "branch": "amd-staging-drm-next" - } - }, - "target_kernel_tree": "linux", - "git_am_options": "--foo-bar foobar -s -n -o -r -l -a -x", - "git_am_branch_prefix": "really-creative-prefix-", - "stay_on_applied_branch": false - }); - serde_json::to_string_pretty(&v).expect("config serializes") + /// Fully-populated config file content, with paths under `root` so + /// `ensure_directories` stays inside a writable temp tree. After bootstrap, `normalize_derived_paths` + /// overwrites patchset/data paths from `cache_dir` and `data_dir` only (explicit per-field paths + /// in JSON are not preserved). + pub fn config_fixture_json(root: &Path) -> String { + let patchsets_cache_dir = root.join("cachedir").join("path"); + let bookmarked = root.join("bookmarked").join("patchsets.json"); + let mailing = root.join("mailing").join("lists.json"); + let reviewed = root.join("reviewed").join("patchsets.json"); + let logs = root.join("logs"); + let cache_dir = root.join("cache_dir"); + let data_dir = root.join("data_dir"); + + let v = json!({ + "page_size": 1234, + "patchsets_cache_dir": patchsets_cache_dir.to_str(), + "bookmarked_patchsets_path": bookmarked.to_str(), + "mailing_lists_path": mailing.to_str(), + "reviewed_patchsets_path": reviewed.to_str(), + "logs_path": logs.to_str(), + "git_send_email_options": "--long-option value -s -h -o -r -t", + "cache_dir": cache_dir.to_str(), + "data_dir": data_dir.to_str(), + "patch_renderer": "default", + "cover_renderer": "default", + "max_log_age": 42, + "kernel_trees": { + "linux": { + "path": "/home/user/linux", + "branch": "master" + }, + "amd-gfx": { + "path": "/home/user/amd-gfx", + "branch": "amd-staging-drm-next" + } + }, + "target_kernel_tree": "linux", + "git_am_options": "--foo-bar foobar -s -n -o -r -l -a -x", + "git_am_branch_prefix": "really-creative-prefix-", + "stay_on_applied_branch": false + }); + serde_json::to_string_pretty(&v).expect("config serializes") + } } +pub use helpers::*; #[test] fn bootstrap_with_default_values() { diff --git a/src/infrastructure/file_system/tests.rs b/src/infrastructure/file_system/tests.rs index 3e75c1b..43ed314 100644 --- a/src/infrastructure/file_system/tests.rs +++ b/src/infrastructure/file_system/tests.rs @@ -1,30 +1,37 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use super::{FileSystemTrait, OsFileSystem}; -use std::env; use std::fs; -use std::process; - -struct TempDir(PathBuf); - -impl TempDir { - fn new(test_name: &str) -> Self { - let dir = env::temp_dir().join(format!("patch_hub_fs_test_{}_{test_name}", process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - Self(dir) +mod helpers { + + use std::env; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process; + + pub struct TempDir(PathBuf); + + impl TempDir { + pub(super) fn new(test_name: &str) -> Self { + let dir = + env::temp_dir().join(format!("patch_hub_fs_test_{}_{test_name}", process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("dir creates"); + Self(dir) + } + + pub(super) fn path(&self) -> &Path { + &self.0 + } } - fn path(&self) -> &Path { - &self.0 - } -} - -impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } } } +pub use helpers::*; #[test] fn read_to_string_returns_file_contents() { diff --git a/src/infrastructure/process/tests.rs b/src/infrastructure/process/tests.rs index 1c32716..a6ba689 100644 --- a/src/infrastructure/process/tests.rs +++ b/src/infrastructure/process/tests.rs @@ -1,80 +1,83 @@ -use std::{ - io, - os::unix::process::ExitStatusExt, - path::{Path, PathBuf}, - process::ExitStatus, - time::{Duration, Instant}, -}; - -use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; +use std::{io, os::unix::process::ExitStatusExt, process::ExitStatus, time::Duration}; use super::{ FakeProcess, MockProcessTrait, MockRunningProcess, OsProcess, ProcessError, ProcessTrait, RunningProcess, }; use crate::infrastructure::shell::ShellCommand; -use nix::sys::signal::Signal; -use std::env; use std::fs; -use std::process; use tokio::time; +mod helpers { + + use nix::sys::signal::Signal; + use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; + use std::env; + use std::fs; + use std::process; + use std::{ + path::{Path, PathBuf}, + time::{Duration, Instant}, + }; + use tokio::time; + + pub struct TempDir(PathBuf); + + impl TempDir { + pub(super) fn new(test_name: &str) -> Self { + let dir = env::temp_dir().join(format!( + "patch_hub_process_test_{}_{test_name}", + process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("dir creates"); + Self(dir) + } -struct TempDir(PathBuf); - -impl TempDir { - fn new(test_name: &str) -> Self { - let dir = env::temp_dir().join(format!( - "patch_hub_process_test_{}_{test_name}", - process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - Self(dir) - } - - fn path(&self) -> &Path { - &self.0 + pub(super) fn path(&self) -> &Path { + &self.0 + } } -} -impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } } -} -async fn wait_for(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool { - let start = Instant::now(); - while !cond() { - if start.elapsed() > timeout { - return false; + pub async fn wait_for(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool { + let start = Instant::now(); + while !cond() { + if start.elapsed() > timeout { + return false; + } + time::sleep(Duration::from_millis(25)).await; } - time::sleep(Duration::from_millis(25)).await; + true } - true -} -fn pid_is_gone(pid: i32) -> bool { - matches!(kill(Pid::from_raw(pid), None::), Err(Errno::ESRCH)) -} + pub fn pid_is_gone(pid: i32) -> bool { + matches!(kill(Pid::from_raw(pid), None::), Err(Errno::ESRCH)) + } -// The shell redirection creates the sidecar file before `echo $!` writes into -// it, so polling for existence can observe an empty file; poll for parseable -// content instead. -async fn await_sidecar_pid(path: &Path) -> i32 { - let mut pid = None; - wait_for( - || { - pid = fs::read_to_string(path) - .ok() - .and_then(|contents| contents.trim().parse::().ok()); - pid.is_some() - }, - Duration::from_secs(2), - ) - .await; - pid.expect("sidecar never received a grandchild pid") + // The shell redirection creates the sidecar file before `echo $!` writes into + // it, so polling for existence can observe an empty file; poll for parseable + // content instead. + pub async fn await_sidecar_pid(path: &Path) -> i32 { + let mut pid = None; + wait_for( + || { + pid = fs::read_to_string(path) + .ok() + .and_then(|contents| contents.trim().parse::().ok()); + pid.is_some() + }, + Duration::from_secs(2), + ) + .await; + pid.expect("sidecar never received a grandchild pid") + } } +pub use helpers::*; #[tokio::test] async fn spawn_returns_while_process_still_running() { diff --git a/src/input/actor.rs b/src/input/actor.rs index 663cf4b..8bccba7 100644 --- a/src/input/actor.rs +++ b/src/input/actor.rs @@ -137,42 +137,52 @@ impl InputActor { #[cfg(test)] mod tests { + + mod helpers { + + use super::super::*; + use crate::{ + app::screens::CurrentScreen, + input::context::InputContext, + terminal::{actor::TerminalActor, session::MockTerminalSessionApi}, + }; + + pub(super) fn mailing_list_context() -> InputContext { + InputContext { + current_screen: CurrentScreen::MailingListSelection, + ..Default::default() + } + } + + pub(super) fn details_context() -> InputContext { + InputContext { + current_screen: CurrentScreen::PatchsetDetails, + ..Default::default() + } + } + + pub(super) fn spawn_test_actor( + session: MockTerminalSessionApi, + context: InputContext, + ) -> (InputHandle, TerminalHandle) { + let terminal_handle = TerminalActor::spawn(Box::new(session)); + let input_handle = InputActor::spawn(terminal_handle.clone(), context); + (input_handle, terminal_handle) + } + } + use helpers::*; use ratatui::crossterm::event::KeyCode; use crate::{ - app::screens::CurrentScreen, input::{ context::InputContext, event::{InputEvent, KeyInput, TerminalEvent}, }, - terminal::{actor::TerminalActor, session::MockTerminalSessionApi, TerminalError}, + terminal::{session::MockTerminalSessionApi, TerminalError}, }; use super::*; - fn mailing_list_context() -> InputContext { - InputContext { - current_screen: CurrentScreen::MailingListSelection, - ..Default::default() - } - } - - fn details_context() -> InputContext { - InputContext { - current_screen: CurrentScreen::PatchsetDetails, - ..Default::default() - } - } - - fn spawn_test_actor( - session: MockTerminalSessionApi, - context: InputContext, - ) -> (InputHandle, TerminalHandle) { - let terminal_handle = TerminalActor::spawn(Box::new(session)); - let input_handle = InputActor::spawn(terminal_handle.clone(), context); - (input_handle, terminal_handle) - } - #[tokio::test] async fn key_event_in_mailing_list_context_delivers_navigate_down() { let mut session = MockTerminalSessionApi::new(); diff --git a/src/input/mapper.rs b/src/input/mapper.rs index e4bb2c9..ea3253e 100644 --- a/src/input/mapper.rs +++ b/src/input/mapper.rs @@ -247,6 +247,40 @@ impl Default for InputMapper { #[cfg(test)] mod tests { + + mod helpers { + use crate::{ + app::screens::CurrentScreen, + input::{ + context::InputContext, + event::{KeyInput, TerminalEvent}, + }, + }; + use ratatui::crossterm::event::{KeyCode, KeyModifiers}; + + pub(super) fn context(current_screen: CurrentScreen) -> InputContext { + InputContext { + current_screen, + ..Default::default() + } + } + + pub(super) fn key(code: KeyCode) -> TerminalEvent { + TerminalEvent::Key(KeyInput { + code, + ..Default::default() + }) + } + + pub(super) fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> TerminalEvent { + TerminalEvent::Key(KeyInput { + code, + modifiers, + ..Default::default() + }) + } + } + use helpers::*; use ratatui::crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use crate::{ @@ -258,28 +292,6 @@ mod tests { }, }; - fn context(current_screen: CurrentScreen) -> InputContext { - InputContext { - current_screen, - ..Default::default() - } - } - - fn key(code: KeyCode) -> TerminalEvent { - TerminalEvent::Key(KeyInput { - code, - ..Default::default() - }) - } - - fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> TerminalEvent { - TerminalEvent::Key(KeyInput { - code, - modifiers, - ..Default::default() - }) - } - #[test] fn maps_escape_to_close_popup_when_popup_is_open() { let mut mapper = InputMapper::default(); diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index d4695ae..4d65f0c 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -2,7 +2,7 @@ use std::{ io, path::Path, sync::{ - atomic::{AtomicBool, AtomicU64, Ordering}, + atomic::{AtomicU64, Ordering}, Mutex, }, time::Duration, @@ -13,14 +13,12 @@ use crate::{ env::MockEnvTrait, file_system::{FileSystemError, MockFileSystemTrait}, process::FakeProcess, - shell::{MockShellTrait, ShellCommand, ShellOutput}, + shell::{MockShellTrait, ShellOutput}, }, kw::{ errors::KwStartError, history::MockKwHistoryStore, - messages::{DeployOptions, StartRequest}, models::{ - history::{KwApplyRecord, KwBuildRecord}, readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, remote::RemoteRefusal, }, @@ -32,173 +30,121 @@ use super::*; use chrono::DateTime; use std::env; use std::fs; -use std::process; use tokio::time; - -static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - -/// A real directory: FakeProcess creates the log file on spawn, so the -/// parent must exist even though the fs trait is mocked. -fn tmp_log_dir(test_name: &str) -> PathBuf { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = env::temp_dir().join(format!( - "patch-hub-kw-actor-{}-{test_name}-{n}", - process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - dir -} - -fn kernel_tree(path: &Path) -> KernelTree { - serde_json::from_value(serde_json::json!({ - "path": path.to_str().expect("path is utf-8"), - "branch": "master" - })) - .expect("json parses") -} - -fn start_request() -> StartRequest { - StartRequest { - kernel_tree_id: "mainline".to_string(), - tree: kernel_tree(Path::new("/home/user/linux")), - branch: "patchset-2026-08-01-17-30-00".to_string(), - extra_args: Vec::new(), - deploy: None, +mod helpers { + use super::super::*; + use crate::{ + infrastructure::{ + env::MockEnvTrait, + file_system::{FileSystemError, MockFileSystemTrait}, + process::FakeProcess, + shell::{MockShellTrait, ShellCommand, ShellOutput}, + }, + kw::{ + history::MockKwHistoryStore, + messages::{DeployOptions, StartRequest}, + models::history::{KwApplyRecord, KwBuildRecord}, + status::{KwJobStatus, KwPhase}, + }, + }; + + use std::env; + use std::fs; + use std::process; + use std::{ + io, + path::Path, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Mutex, + }, + time::Duration, + }; + use tokio::time; + + pub static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + + /// A real directory: FakeProcess creates the log file on spawn, so the + /// parent must exist even though the fs trait is mocked. + pub fn tmp_log_dir(test_name: &str) -> PathBuf { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let dir = env::temp_dir().join(format!( + "patch-hub-kw-actor-{}-{test_name}-{n}", + process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("dir creates"); + dir } -} -/// Spawns the actor with the already-configured mocks (mockall -/// expectations need `&mut`, so they are set before the mocks move -/// behind `Arc`s). The log dir is a real unique temp dir even though -/// these tests never start a job, so a future test that accidentally -/// does cannot share a fixed path with the job tests. -fn spawn_test_actor( - test_name: &str, - history: MockKwHistoryStore, - shell: MockShellTrait, - fs: MockFileSystemTrait, - env: MockEnvTrait, -) -> KwHandle { - KwActor::spawn( - Arc::new(history), - Arc::new(FakeProcess::new()), - Arc::new(shell), - Arc::new(fs), - Arc::new(env), - tmp_log_dir(test_name), - ) -} - -const KW_VERSION_OK: &[u8] = b"kw, version 0.10.0\n"; -/// A clean `git status --porcelain` answer. -const CLEAN_STATUS: (&[u8], &[u8], bool) = (b"", b"", true); -/// A successful `git switch` answer. -const SWITCH_OK: (&[u8], bool) = (b"", true); - -fn command_parts(cmd: &ShellCommand) -> Vec { - let mut parts = vec![cmd.program.clone()]; - parts.extend(cmd.args.clone()); - parts -} + pub fn kernel_tree(path: &Path) -> KernelTree { + serde_json::from_value(serde_json::json!({ + "path": path.to_str().expect("path is utf-8"), + "branch": "master" + })) + .expect("json parses") + } -fn command(parts: &[&str]) -> Vec { - parts.iter().map(|part| part.to_string()).collect() -} + pub fn start_request() -> StartRequest { + StartRequest { + kernel_tree_id: "mainline".to_string(), + tree: kernel_tree(Path::new("/home/user/linux")), + branch: "patchset-2026-08-01-17-30-00".to_string(), + extra_args: Vec::new(), + deploy: None, + } + } -/// A shell mock that logs every command's argv parts and answers by -/// content: kw's version probe with `kw_version`; `git status -/// --porcelain` with `status` (stdout, stderr, success); `git switch` -/// with `switch` (stderr, success); any other git call — the HEAD -/// branch probe — with `master`. -fn recording_shell( - kw_version: &'static [u8], - status: (&'static [u8], &'static [u8], bool), - switch: (&'static [u8], bool), -) -> (MockShellTrait, Arc>>>) { - let calls = Arc::new(Mutex::new(Vec::new())); - let calls_in_shell = Arc::clone(&calls); - let mut shell = MockShellTrait::new(); - shell - .expect_execute() - .withf(|cmd| { - cmd.program == "kw" && cmd.args == ["--version"] - || cmd.program == "git" - && cmd.args - == [ - "-C", - "/home/user/linux", - "status", - "--porcelain", - "--untracked-files=no", - ] - || cmd.program == "git" - && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] - || cmd.program == "git" - && cmd.args - == [ - "-C", - "/home/user/linux", - "switch", - "--", - "patchset-2026-08-01-17-30-00", - ] - || cmd.program == "git" - && cmd.args == ["-C", "/home/user/linux", "switch", "--", "master"] - }) - .times(2..=9) - .returning(move |cmd| { - calls_in_shell - .lock() - .expect("calls in shell locks") - .push(command_parts(cmd)); - let output = |stdout: &[u8], stderr: &[u8], success: bool| ShellOutput { - stdout: stdout.to_vec(), - stderr: stderr.to_vec(), - success, - }; - if cmd.program == "kw" { - return Ok(output(kw_version, b"", true)); - } - if cmd.args.iter().any(|arg| arg == "status") { - return Ok(output(status.0, status.1, status.2)); - } - if cmd.args.iter().any(|arg| arg == "switch") { - return Ok(output(b"", switch.0, switch.1)); - } - Ok(output(b"master\n", b"", true)) - }); - (shell, calls) -} + /// Spawns the actor with the already-configured mocks (mockall + /// expectations need `&mut`, so they are set before the mocks move + /// behind `Arc`s). The log dir is a real unique temp dir even though + /// these tests never start a job, so a future test that accidentally + /// does cannot share a fixed path with the job tests. + pub fn spawn_test_actor( + test_name: &str, + history: MockKwHistoryStore, + shell: MockShellTrait, + fs: MockFileSystemTrait, + env: MockEnvTrait, + ) -> KwHandle { + KwActor::spawn( + Arc::new(history), + Arc::new(FakeProcess::new()), + Arc::new(shell), + Arc::new(fs), + Arc::new(env), + tmp_log_dir(test_name), + ) + } -/// Shell double for checkout/restore tests: kw version probe answers -/// 0.10.0, `git status --porcelain` reflects the dirty flag, the HEAD -/// probe reports `head` (including git's trailing newline), and a -/// successful `git switch` updates `head`. Switches to `fail_switch_to` -/// fail, so going forward can succeed while coming back fails. -pub(super) struct GitStub { - head: Arc>, - dirty: Arc, - fail_switch_to: Arc>>, -} + pub const KW_VERSION_OK: &[u8] = b"kw, version 0.10.0\n"; + /// A clean `git status --porcelain` answer. + pub const CLEAN_STATUS: (&[u8], &[u8], bool) = (b"", b"", true); + /// A successful `git switch` answer. + pub const SWITCH_OK: (&[u8], bool) = (b"", true); -impl GitStub { - pub(super) fn on_branch(branch: &str) -> Self { - Self { - head: Arc::new(Mutex::new(branch.to_string())), - dirty: Arc::new(AtomicBool::new(false)), - fail_switch_to: Arc::new(Mutex::new(None)), - } + pub fn command_parts(cmd: &ShellCommand) -> Vec { + let mut parts = vec![cmd.program.clone()]; + parts.extend(cmd.args.clone()); + parts } - pub(super) fn head(&self) -> String { - self.head.lock().expect("head locks").clone() + pub fn command(parts: &[&str]) -> Vec { + parts.iter().map(|part| part.to_string()).collect() } - pub(super) fn shell(&self) -> MockShellTrait { - let head = Arc::clone(&self.head); - let dirty = Arc::clone(&self.dirty); - let fail_switch_to = Arc::clone(&self.fail_switch_to); + /// A shell mock that logs every command's argv parts and answers by + /// content: kw's version probe with `kw_version`; `git status + /// --porcelain` with `status` (stdout, stderr, success); `git switch` + /// with `switch` (stderr, success); any other git call — the HEAD + /// branch probe — with `master`. + pub fn recording_shell( + kw_version: &'static [u8], + status: (&'static [u8], &'static [u8], bool), + switch: (&'static [u8], bool), + ) -> (MockShellTrait, Arc>>>) { + let calls = Arc::new(Mutex::new(Vec::new())); + let calls_in_shell = Arc::clone(&calls); let mut shell = MockShellTrait::new(); shell .expect_execute() @@ -226,496 +172,624 @@ impl GitStub { ] || cmd.program == "git" && cmd.args == ["-C", "/home/user/linux", "switch", "--", "master"] - || cmd.program == "git" - && cmd.args == ["-C", "/home/user/linux", "switch", "--", "patchset-two"] }) - .times(5..=11) + .times(2..=9) .returning(move |cmd| { - let output = |stdout: &[u8]| ShellOutput { + calls_in_shell + .lock() + .expect("calls in shell locks") + .push(command_parts(cmd)); + let output = |stdout: &[u8], stderr: &[u8], success: bool| ShellOutput { stdout: stdout.to_vec(), - stderr: Vec::new(), - success: true, + stderr: stderr.to_vec(), + success, }; if cmd.program == "kw" { - return Ok(output(KW_VERSION_OK)); + return Ok(output(kw_version, b"", true)); } if cmd.args.iter().any(|arg| arg == "status") { - let stdout: &[u8] = if dirty.load(Ordering::Relaxed) { - b" M src/main.c\n" - } else { - b"" - }; - return Ok(output(stdout)); + return Ok(output(status.0, status.1, status.2)); } if cmd.args.iter().any(|arg| arg == "switch") { - let branch = cmd.args.last().expect("iterator yields last").clone(); - if fail_switch_to - .lock() - .expect("fail switch to locks") - .as_deref() - == Some(branch.as_str()) - { - return Ok(ShellOutput { - stdout: Vec::new(), - stderr: b"error: you need to resolve your current index first\n" - .to_vec(), - success: false, - }); - } - *head.lock().expect("head locks") = branch; - return Ok(output(b"")); + return Ok(output(b"", switch.0, switch.1)); } - let current = format!("{}\n", head.lock().expect("head locks")); - Ok(output(current.as_bytes())) + Ok(output(b"master\n", b"", true)) }); - shell + (shell, calls) } -} -impl GitStub { - fn set_dirty(&self, dirty: bool) { - self.dirty.store(dirty, Ordering::Relaxed); + /// Shell double for checkout/restore tests: kw version probe answers + /// 0.10.0, `git status --porcelain` reflects the dirty flag, the HEAD + /// probe reports `head` (including git's trailing newline), and a + /// successful `git switch` updates `head`. Switches to `fail_switch_to` + /// fail, so going forward can succeed while coming back fails. + pub struct GitStub { + head: Arc>, + dirty: Arc, + fail_switch_to: Arc>>, } - fn fail_switches_to(&self, branch: Option<&str>) { - *self.fail_switch_to.lock().expect("fail switch to locks") = branch.map(str::to_string); + impl GitStub { + pub fn on_branch(branch: &str) -> Self { + Self { + head: Arc::new(Mutex::new(branch.to_string())), + dirty: Arc::new(AtomicBool::new(false)), + fail_switch_to: Arc::new(Mutex::new(None)), + } + } + + pub fn head(&self) -> String { + self.head.lock().expect("head locks").clone() + } + + pub fn shell(&self) -> MockShellTrait { + let head = Arc::clone(&self.head); + let dirty = Arc::clone(&self.dirty); + let fail_switch_to = Arc::clone(&self.fail_switch_to); + let mut shell = MockShellTrait::new(); + shell + .expect_execute() + .withf(|cmd| { + cmd.program == "kw" && cmd.args == ["--version"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/home/user/linux", + "status", + "--porcelain", + "--untracked-files=no", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "branch", "--show-current"] + || cmd.program == "git" + && cmd.args + == [ + "-C", + "/home/user/linux", + "switch", + "--", + "patchset-2026-08-01-17-30-00", + ] + || cmd.program == "git" + && cmd.args == ["-C", "/home/user/linux", "switch", "--", "master"] + || cmd.program == "git" + && cmd.args + == ["-C", "/home/user/linux", "switch", "--", "patchset-two"] + }) + .times(5..=11) + .returning(move |cmd| { + let output = |stdout: &[u8]| ShellOutput { + stdout: stdout.to_vec(), + stderr: Vec::new(), + success: true, + }; + if cmd.program == "kw" { + return Ok(output(KW_VERSION_OK)); + } + if cmd.args.iter().any(|arg| arg == "status") { + let stdout: &[u8] = if dirty.load(Ordering::Relaxed) { + b" M src/main.c\n" + } else { + b"" + }; + return Ok(output(stdout)); + } + if cmd.args.iter().any(|arg| arg == "switch") { + let branch = cmd.args.last().expect("iterator yields last").clone(); + if fail_switch_to + .lock() + .expect("fail switch to locks") + .as_deref() + == Some(branch.as_str()) + { + return Ok(ShellOutput { + stdout: Vec::new(), + stderr: b"error: you need to resolve your current index first\n" + .to_vec(), + success: false, + }); + } + *head.lock().expect("head locks") = branch; + return Ok(output(b"")); + } + let current = format!("{}\n", head.lock().expect("head locks")); + Ok(output(current.as_bytes())) + }); + shell + } } -} -/// fs answers for a ready kernel tree with no active kw env: the -/// kernel-root probes pass, `.config` exists, `.kw/env.current` is -/// absent, `.kw/build.config` is unreadable (arch probes as None), -/// and there is no arch/ dir to glob images from. -fn expect_ready_tree(fs: &mut MockFileSystemTrait) { - fs.expect_is_dir() - .withf(|path| { - path == std::path::Path::new("/home/user/linux") - || path == std::path::Path::new("/home/user/linux/.kw") - || path == std::path::Path::new("/home/user/linux/Documentation") - || path == std::path::Path::new("/home/user/linux/arch") - || path == std::path::Path::new("/home/user/linux/drivers") - || path == std::path::Path::new("/home/user/linux/fs") - || path == std::path::Path::new("/home/user/linux/include") - || path == std::path::Path::new("/home/user/linux/init") - || path == std::path::Path::new("/home/user/linux/ipc") - || path == std::path::Path::new("/home/user/linux/kernel") - || path == std::path::Path::new("/home/user/linux/lib") - || path == std::path::Path::new("/home/user/linux/scripts") - }) - .times(12..=24) - .returning(|_| true); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/home/user/linux/.config") - || path == std::path::Path::new("/home/user/linux/.kw/env.current") - || path == std::path::Path::new("/home/user/linux/COPYING") - || path == std::path::Path::new("/home/user/linux/CREDITS") - || path == std::path::Path::new("/home/user/linux/Kbuild") - || path == std::path::Path::new("/home/user/linux/Makefile") - || path == std::path::Path::new("/home/user/linux/README") - }) - .times(7..=14) - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists() - .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) - .times(1..=2) - .returning(|_| true); - fs.expect_read_to_string() - .withf(|path| { - path == std::path::Path::new("/home/user/linux/.kw/build.config") - || path == std::path::Path::new("/home/user/linux/include/config/kernel.release") - || path.extension() == Some("log".as_ref()) - }) - .times(1..=3) - .returning(|path| { - read_real_job_log(path).unwrap_or_else(|| { + impl GitStub { + pub(super) fn set_dirty(&self, dirty: bool) { + self.dirty.store(dirty, Ordering::Relaxed); + } + + pub(super) fn fail_switches_to(&self, branch: Option<&str>) { + *self.fail_switch_to.lock().expect("fail switch to locks") = branch.map(str::to_string); + } + } + + /// fs answers for a ready kernel tree with no active kw env: the + /// kernel-root probes pass, `.config` exists, `.kw/env.current` is + /// absent, `.kw/build.config` is unreadable (arch probes as None), + /// and there is no arch/ dir to glob images from. + pub fn expect_ready_tree(fs: &mut MockFileSystemTrait) { + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(12..=24) + .returning(|_| true); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + }) + .times(7..=14) + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1..=2) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/build.config") + || path + == std::path::Path::new("/home/user/linux/include/config/kernel.release") + || path.extension() == Some("log".as_ref()) + }) + .times(1..=3) + .returning(|path| { + read_real_job_log(path).unwrap_or_else(|| { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + }) + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch")) + .times(0..=1) + .returning(|_| { Err(FileSystemError::IoError(io::Error::new( io::ErrorKind::NotFound, "missing", ))) - }) - }); - fs.expect_read_dir() - .withf(|path| path == std::path::Path::new("/home/user/linux/arch")) - .times(0..=1) - .returning(|_| { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - }); -} + }); + } -/// Job logs are real files ([`FakeProcess`] creates them and -/// `write_log` appends to them), so fs doubles read `*.log` paths from -/// disk. -fn read_real_job_log(path: &Path) -> Option> { - (path.extension() == Some("log".as_ref())) - .then(|| fs::read_to_string(path).map_err(FileSystemError::from)) -} + /// Job logs are real files ([`FakeProcess`] creates them and + /// `write_log` appends to them), so fs doubles read `*.log` paths from + /// disk. + pub fn read_real_job_log(path: &Path) -> Option> { + (path.extension() == Some("log".as_ref())) + .then(|| fs::read_to_string(path).map_err(FileSystemError::from)) + } -/// A ready kernel tree whose log dir can be created. -fn ready_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - expect_ready_tree(&mut fs); - fs.expect_create_dir_all() - .withf(|path| path.starts_with(std::env::temp_dir())) - .times(1..=2) - .returning(|_| Ok(())); - fs -} + /// A ready kernel tree whose log dir can be created. + pub fn ready_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + expect_ready_tree(&mut fs); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1..=2) + .returning(|_| Ok(())); + fs + } -/// A ready kernel tree whose build produced an image and a -/// kernelrelease: build.config sets `arch=x86`, `arch/x86/boot/` -/// holds a bzImage, and `include/config/kernel.release` exists. The -/// image's metadata is unreadable, so its mtime falls back to the -/// epoch — still the only, hence newest, candidate. -fn built_tree_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .withf(|path| { - path == std::path::Path::new("/home/user/linux") - || path == std::path::Path::new("/home/user/linux/.kw") - || path == std::path::Path::new("/home/user/linux/Documentation") - || path == std::path::Path::new("/home/user/linux/arch") - || path == std::path::Path::new("/home/user/linux/drivers") - || path == std::path::Path::new("/home/user/linux/fs") - || path == std::path::Path::new("/home/user/linux/include") - || path == std::path::Path::new("/home/user/linux/init") - || path == std::path::Path::new("/home/user/linux/ipc") - || path == std::path::Path::new("/home/user/linux/kernel") - || path == std::path::Path::new("/home/user/linux/lib") - || path == std::path::Path::new("/home/user/linux/scripts") - }) - .times(12) - .returning(|_| true); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/home/user/linux/.config") - || path == std::path::Path::new("/home/user/linux/.kw/env.current") - || path == std::path::Path::new("/home/user/linux/COPYING") - || path == std::path::Path::new("/home/user/linux/CREDITS") - || path == std::path::Path::new("/home/user/linux/Kbuild") - || path == std::path::Path::new("/home/user/linux/Makefile") - || path == std::path::Path::new("/home/user/linux/README") - || path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage") - }) - .times(7..=8) - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists() - .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) - .times(1) - .returning(|_| true); - fs.expect_read_to_string() - .withf(|path| { - path == std::path::Path::new("/home/user/linux/.kw/build.config") - || path == std::path::Path::new("/home/user/linux/include/config/kernel.release") - || path.extension() == Some("log".as_ref()) - }) - .times(2) - .returning(|path| { - if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_read_dir() - .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot")) - .times(0..=1) - .returning(|path| { - if path.ends_with("arch/x86/boot") { - Ok(vec![PathBuf::from( - "/home/user/linux/arch/x86/boot/bzImage", - )]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_metadata() - .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage")) - .times(0..=1) - .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all() - .withf(|path| path.starts_with(std::env::temp_dir())) - .times(1) - .returning(|_| Ok(())); - fs -} + /// A ready kernel tree whose build produced an image and a + /// kernelrelease: build.config sets `arch=x86`, `arch/x86/boot/` + /// holds a bzImage, and `include/config/kernel.release` exists. The + /// image's metadata is unreadable, so its mtime falls back to the + /// epoch — still the only, hence newest, candidate. + pub fn built_tree_fs() -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(12) + .returning(|_| true); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + || path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage") + }) + .times(7..=8) + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/build.config") + || path + == std::path::Path::new("/home/user/linux/include/config/kernel.release") + || path.extension() == Some("log".as_ref()) + }) + .times(2) + .returning(|path| { + if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot")) + .times(0..=1) + .returning(|path| { + if path.ends_with("arch/x86/boot") { + Ok(vec![PathBuf::from( + "/home/user/linux/arch/x86/boot/bzImage", + )]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_metadata() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage")) + .times(0..=1) + .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); + fs + } -pub(super) const DEPLOY_REMOTE_CONFIG: &str = - "#kw-default=dut\nHost dut\n Hostname box\n Port 22\n User root\n"; -pub(super) const DEPLOY_BOOT_ONCE_OFF: &str = "boot_into_new_kernel_once=no\n"; -pub(super) const DEPLOY_BOOT_ONCE_ON: &str = "boot_into_new_kernel_once=yes\n"; + pub const DEPLOY_REMOTE_CONFIG: &str = + "#kw-default=dut\nHost dut\n Hostname box\n Port 22\n User root\n"; + pub const DEPLOY_BOOT_ONCE_OFF: &str = "boot_into_new_kernel_once=no\n"; + pub const DEPLOY_BOOT_ONCE_ON: &str = "boot_into_new_kernel_once=yes\n"; -pub(super) fn deploy_ready_fs() -> MockFileSystemTrait { - deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, true) -} + pub fn deploy_ready_fs() -> MockFileSystemTrait { + deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, true) + } -pub(super) fn deploy_fs( - remote_config: &'static str, - deploy_config: &'static str, - has_image: bool, -) -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .withf(|path| { - path == std::path::Path::new("/home/user/linux") - || path == std::path::Path::new("/home/user/linux/.kw") - || path == std::path::Path::new("/home/user/linux/Documentation") - || path == std::path::Path::new("/home/user/linux/arch") - || path == std::path::Path::new("/home/user/linux/drivers") - || path == std::path::Path::new("/home/user/linux/fs") - || path == std::path::Path::new("/home/user/linux/include") - || path == std::path::Path::new("/home/user/linux/init") - || path == std::path::Path::new("/home/user/linux/ipc") - || path == std::path::Path::new("/home/user/linux/kernel") - || path == std::path::Path::new("/home/user/linux/lib") - || path == std::path::Path::new("/home/user/linux/scripts") - }) - .times(12) - .returning(|_| true); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/home/user/linux/.config") - || path == std::path::Path::new("/home/user/linux/.kw/deploy.config") - || path == std::path::Path::new("/home/user/linux/.kw/env.current") - || path == std::path::Path::new("/home/user/linux/.kw/remote.config") - || path == std::path::Path::new("/home/user/linux/COPYING") - || path == std::path::Path::new("/home/user/linux/CREDITS") - || path == std::path::Path::new("/home/user/linux/Kbuild") - || path == std::path::Path::new("/home/user/linux/Makefile") - || path == std::path::Path::new("/home/user/linux/README") - || path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage") - }) - .times(8..=10) - .returning(|path| !path.ends_with(".kw/env.current")); - fs.expect_exists() - .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) - .times(1) - .returning(|_| true); - fs.expect_read_to_string() - .withf(|path| { - path == std::path::Path::new("/home/user/linux/.kw/build.config") - || path == std::path::Path::new("/home/user/linux/.kw/deploy.config") - || path == std::path::Path::new("/home/user/linux/.kw/remote.config") - || path == std::path::Path::new("/home/user/linux/include/config/kernel.release") - || path.extension() == Some("log".as_ref()) - }) - .times(2..=5) - .returning(move |path| { - if let Some(log) = read_real_job_log(path) { - log - } else if path.ends_with("build.config") { - Ok("arch=x86\n".to_string()) - } else if path.ends_with("kernel.release") { - Ok("6.17.0\n".to_string()) - } else if path.ends_with("remote.config") { - Ok(remote_config.to_string()) - } else if path.ends_with("deploy.config") { - Ok(deploy_config.to_string()) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_read_dir() - .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot")) - .times(0..=1) - .returning(move |path| { - if has_image && path.ends_with("arch/x86/boot") { - Ok(vec![PathBuf::from( - "/home/user/linux/arch/x86/boot/bzImage", - )]) - } else { - Err(FileSystemError::IoError(io::Error::new( - io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_metadata() - .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage")) - .times(0..=1) - .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); - fs.expect_create_dir_all() - .withf(|path| path.starts_with(std::env::temp_dir())) - .times(0..=2) - .returning(|_| Ok(())); - fs -} + pub fn deploy_fs( + remote_config: &'static str, + deploy_config: &'static str, + has_image: bool, + ) -> MockFileSystemTrait { + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_dir() + .withf(|path| { + path == std::path::Path::new("/home/user/linux") + || path == std::path::Path::new("/home/user/linux/.kw") + || path == std::path::Path::new("/home/user/linux/Documentation") + || path == std::path::Path::new("/home/user/linux/arch") + || path == std::path::Path::new("/home/user/linux/drivers") + || path == std::path::Path::new("/home/user/linux/fs") + || path == std::path::Path::new("/home/user/linux/include") + || path == std::path::Path::new("/home/user/linux/init") + || path == std::path::Path::new("/home/user/linux/ipc") + || path == std::path::Path::new("/home/user/linux/kernel") + || path == std::path::Path::new("/home/user/linux/lib") + || path == std::path::Path::new("/home/user/linux/scripts") + }) + .times(12) + .returning(|_| true); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.config") + || path == std::path::Path::new("/home/user/linux/.kw/deploy.config") + || path == std::path::Path::new("/home/user/linux/.kw/env.current") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path == std::path::Path::new("/home/user/linux/COPYING") + || path == std::path::Path::new("/home/user/linux/CREDITS") + || path == std::path::Path::new("/home/user/linux/Kbuild") + || path == std::path::Path::new("/home/user/linux/Makefile") + || path == std::path::Path::new("/home/user/linux/README") + || path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage") + }) + .times(8..=10) + .returning(|path| !path.ends_with(".kw/env.current")); + fs.expect_exists() + .withf(|path| path == std::path::Path::new("/home/user/linux/MAINTAINERS")) + .times(1) + .returning(|_| true); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/linux/.kw/build.config") + || path == std::path::Path::new("/home/user/linux/.kw/deploy.config") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path + == std::path::Path::new("/home/user/linux/include/config/kernel.release") + || path.extension() == Some("log".as_ref()) + }) + .times(2..=5) + .returning(move |path| { + if let Some(log) = read_real_job_log(path) { + log + } else if path.ends_with("build.config") { + Ok("arch=x86\n".to_string()) + } else if path.ends_with("kernel.release") { + Ok("6.17.0\n".to_string()) + } else if path.ends_with("remote.config") { + Ok(remote_config.to_string()) + } else if path.ends_with("deploy.config") { + Ok(deploy_config.to_string()) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_read_dir() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot")) + .times(0..=1) + .returning(move |path| { + if has_image && path.ends_with("arch/x86/boot") { + Ok(vec![PathBuf::from( + "/home/user/linux/arch/x86/boot/bzImage", + )]) + } else { + Err(FileSystemError::IoError(io::Error::new( + io::ErrorKind::NotFound, + "missing", + ))) + } + }); + fs.expect_metadata() + .withf(|path| path == std::path::Path::new("/home/user/linux/arch/x86/boot/bzImage")) + .times(0..=1) + .returning(|_| Err(FileSystemError::IoError(io::Error::other("no metadata")))); + fs.expect_create_dir_all() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(0..=2) + .returning(|_| Ok(())); + fs + } -pub(super) fn matching_build_record() -> KwBuildRecord { - KwBuildRecord { - kernel_tree_id: "mainline".to_string(), - tree_path: "/home/user/linux".to_string(), - message_id: None, - branch: "patchset-2026-08-01-17-30-00".to_string(), - arch: Some("x86".to_string()), - image_path: Some("/home/user/linux/arch/x86/boot/bzImage".to_string()), - output_dir: None, - kernelrelease: Some("6.17.0".to_string()), - log_path: String::new(), - built_at: "2026-08-01T18:10:00Z".to_string(), - success: true, + pub fn matching_build_record() -> KwBuildRecord { + KwBuildRecord { + kernel_tree_id: "mainline".to_string(), + tree_path: "/home/user/linux".to_string(), + message_id: None, + branch: "patchset-2026-08-01-17-30-00".to_string(), + arch: Some("x86".to_string()), + image_path: Some("/home/user/linux/arch/x86/boot/bzImage".to_string()), + output_dir: None, + kernelrelease: Some("6.17.0".to_string()), + log_path: String::new(), + built_at: "2026-08-01T18:10:00Z".to_string(), + success: true, + } } -} -pub(super) fn deploy_options(acknowledged: bool) -> DeployOptions { - DeployOptions { - reboot: false, - force: true, - boot_once_acknowledged: acknowledged, + pub fn deploy_options(acknowledged: bool) -> DeployOptions { + DeployOptions { + reboot: false, + force: true, + boot_once_acknowledged: acknowledged, + } } -} -pub(super) fn deploy_request() -> StartRequest { - let mut request = start_request(); - request.deploy = Some(deploy_options(false)); - request -} + pub fn deploy_request() -> StartRequest { + let mut request = start_request(); + request.deploy = Some(deploy_options(false)); + request + } -/// History for a deploy-alone start: answers the record lookup for the -/// requested branch and panics if a deploy writes a build record. -pub(super) fn deploy_history(record: Option) -> MockKwHistoryStore { - let mut history = MockKwHistoryStore::new(); - history - .expect_build_records() - .withf(|kernel_tree_id, branch| { - kernel_tree_id == "mainline" && branch == "patchset-2026-08-01-17-30-00" - }) - .times(1) - .returning(move |_, _| Ok((record.clone(), record.clone()))); - history.expect_record_build().withf(|_| true).times(0); - history -} + /// History for a deploy-alone start: answers the record lookup for the + /// requested branch and panics if a deploy writes a build record. + pub fn deploy_history(record: Option) -> MockKwHistoryStore { + let mut history = MockKwHistoryStore::new(); + history + .expect_build_records() + .withf(|kernel_tree_id, branch| { + kernel_tree_id == "mainline" && branch == "patchset-2026-08-01-17-30-00" + }) + .times(1) + .returning(move |_, _| Ok((record.clone(), record.clone()))); + history.expect_record_build().withf(|_| true).times(0); + history + } -pub(super) fn env_with_kw() -> MockEnvTrait { - let mut env = MockEnvTrait::new(); - env.expect_which() - .withf(|name| name == "kw") - .times(1) - .returning(|_| true); - env -} + pub fn env_with_kw() -> MockEnvTrait { + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + } -pub(super) fn spawn_deploy_actor( - test_name: &str, - history: MockKwHistoryStore, - fs: MockFileSystemTrait, -) -> (KwHandle, Arc, PathBuf) { - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - spawn_full_actor(test_name, history, shell, fs, env_with_kw()) -} + pub fn spawn_deploy_actor( + test_name: &str, + history: MockKwHistoryStore, + fs: MockFileSystemTrait, + ) -> (KwHandle, Arc, PathBuf) { + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + spawn_full_actor(test_name, history, shell, fs, env_with_kw()) + } -/// History answers for an actor whose jobs complete: no patchset -/// link, build-record writes accepted and dropped. -pub(super) fn quiet_history() -> MockKwHistoryStore { - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") - .times(1) - .returning(|_, _| Ok(None)); - history - .expect_record_build() - .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") - .times(1) - .returning(|_| Ok(())); - history -} + /// History answers for an actor whose jobs complete: no patchset + /// link, build-record writes accepted and dropped. + pub fn quiet_history() -> MockKwHistoryStore { + let mut history = MockKwHistoryStore::new(); + history + .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(|_, _| Ok(None)); + history + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(|_| Ok(())); + history + } -/// A history double that captures written build records and answers -/// the patchset-link lookup with `apply_record`. -pub(super) fn recording_history( - apply_record: Option, -) -> (MockKwHistoryStore, Arc>>) { - let builds = Arc::new(Mutex::new(Vec::new())); - let builds_in_store = Arc::clone(&builds); - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") - .times(1) - .returning(move |_, _| Ok(apply_record.clone())); - history - .expect_record_build() - .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") - .times(1) - .returning(move |record| { - builds_in_store - .lock() - .expect("builds in store locks") - .push(record); - Ok(()) - }); - (history, builds) -} + /// A history double that captures written build records and answers + /// the patchset-link lookup with `apply_record`. + pub fn recording_history( + apply_record: Option, + ) -> (MockKwHistoryStore, Arc>>) { + let builds = Arc::new(Mutex::new(Vec::new())); + let builds_in_store = Arc::clone(&builds); + let mut history = MockKwHistoryStore::new(); + history + .expect_apply_record_for_branch() + .withf(|tree, branch| tree == "mainline" && branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(move |_, _| Ok(apply_record.clone())); + history + .expect_record_build() + .withf(|record| record.branch == "patchset-2026-08-01-17-30-00") + .times(1) + .returning(move |record| { + builds_in_store + .lock() + .expect("builds in store locks") + .push(record); + Ok(()) + }); + (history, builds) + } -/// Spawns the actor with every dependency explicit, a real temp log -/// dir, and the [`FakeProcess`] exposed so tests drive the "running" -/// process. -pub(super) fn spawn_full_actor( - test_name: &str, - history: MockKwHistoryStore, - shell: MockShellTrait, - fs: MockFileSystemTrait, - env: MockEnvTrait, -) -> (KwHandle, Arc, PathBuf) { - let process = Arc::new(FakeProcess::new()); - let log_dir = tmp_log_dir(test_name); - let handle = KwActor::spawn( - Arc::new(history), - process.clone(), - Arc::new(shell), - Arc::new(fs), - Arc::new(env), - log_dir.clone(), - ); - (handle, process, log_dir) -} + /// Spawns the actor with every dependency explicit, a real temp log + /// dir, and the [`FakeProcess`] exposed so tests drive the "running" + /// process. + pub fn spawn_full_actor( + test_name: &str, + history: MockKwHistoryStore, + shell: MockShellTrait, + fs: MockFileSystemTrait, + env: MockEnvTrait, + ) -> (KwHandle, Arc, PathBuf) { + let process = Arc::new(FakeProcess::new()); + let log_dir = tmp_log_dir(test_name); + let handle = KwActor::spawn( + Arc::new(history), + process.clone(), + Arc::new(shell), + Arc::new(fs), + Arc::new(env), + log_dir.clone(), + ); + (handle, process, log_dir) + } -/// Spawns the actor with a real temp log dir and exposes the -/// [`FakeProcess`] so tests drive the "running" process. The env mock -/// has kw on PATH. -fn spawn_job_actor_with_mocks( - test_name: &str, - shell: MockShellTrait, - fs: MockFileSystemTrait, -) -> (KwHandle, Arc, PathBuf) { - let mut env = MockEnvTrait::new(); - env.expect_which() - .withf(|name| name == "kw") - .times(1..=2) - .returning(|_| true); - spawn_full_actor(test_name, quiet_history(), shell, fs, env) -} + /// Spawns the actor with a real temp log dir and exposes the + /// [`FakeProcess`] so tests drive the "running" process. The env mock + /// has kw on PATH. + pub fn spawn_job_actor_with_mocks( + test_name: &str, + shell: MockShellTrait, + fs: MockFileSystemTrait, + ) -> (KwHandle, Arc, PathBuf) { + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "kw") + .times(1..=2) + .returning(|_| true); + spawn_full_actor(test_name, quiet_history(), shell, fs, env) + } -fn spawn_job_actor(test_name: &str) -> (KwHandle, Arc, PathBuf) { - let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); - spawn_job_actor_with_mocks(test_name, shell, ready_fs()) -} + pub fn spawn_job_actor(test_name: &str) -> (KwHandle, Arc, PathBuf) { + let (shell, _calls) = recording_shell(KW_VERSION_OK, CLEAN_STATUS, SWITCH_OK); + spawn_job_actor_with_mocks(test_name, shell, ready_fs()) + } + + pub fn apply_record() -> KwApplyRecord { + KwApplyRecord { + message_id: "msg-1".to_string(), + kernel_tree_id: "mainline".to_string(), + tree_path: "/home/user/linux".to_string(), + applied_branch: "patchset-2026-08-01-17-30-00".to_string(), + base_branch: "master".to_string(), + applied_at: "2026-08-01T17:30:00Z".to_string(), + } + } -fn apply_record() -> KwApplyRecord { - KwApplyRecord { - message_id: "msg-1".to_string(), - kernel_tree_id: "mainline".to_string(), - tree_path: "/home/user/linux".to_string(), - applied_branch: "patchset-2026-08-01-17-30-00".to_string(), - base_branch: "master".to_string(), - applied_at: "2026-08-01T17:30:00Z".to_string(), + /// Waits until the status leaves `Idle`/`Running` and returns the + /// terminal status. The receiver may have observed the `Running` + /// transition first, so a single `changed()` is not enough. The timeout + /// backstops against a wedged actor; tests that exercise the grace + /// periods run with paused time instead of waiting them out. + pub async fn wait_for_terminal_status( + watch: &mut watch::Receiver, + ) -> KwJobStatus { + time::timeout(Duration::from_secs(10), async { + loop { + let status = watch.borrow().job.clone(); + if !matches!(status, KwJobStatus::Idle | KwJobStatus::Running { .. }) { + return status; + } + watch.changed().await.expect("watch notifies"); + } + }) + .await + .expect("status must reach a terminal state") + } + + pub async fn wait_for_running_phase( + watch: &mut watch::Receiver, + phase: KwPhase, + ) -> KwJobStatus { + time::timeout(Duration::from_secs(10), async { + loop { + let status = watch.borrow().job.clone(); + if matches!( + status, + KwJobStatus::Running { phase: running, .. } if running == phase + ) { + return status; + } + watch.changed().await.expect("watch notifies"); + } + }) + .await + .expect("status must reach the expected running phase") } } +pub use helpers::*; #[tokio::test] async fn record_apply_writes_through_history_store() { @@ -948,47 +1022,6 @@ async fn get_readiness_for_branch_looks_up_that_branch_not_head() { handle.shutdown().await; } -/// Waits until the status leaves `Idle`/`Running` and returns the -/// terminal status. The receiver may have observed the `Running` -/// transition first, so a single `changed()` is not enough. The timeout -/// backstops against a wedged actor; tests that exercise the grace -/// periods run with paused time instead of waiting them out. -pub(super) async fn wait_for_terminal_status( - watch: &mut watch::Receiver, -) -> KwJobStatus { - time::timeout(Duration::from_secs(10), async { - loop { - let status = watch.borrow().job.clone(); - if !matches!(status, KwJobStatus::Idle | KwJobStatus::Running { .. }) { - return status; - } - watch.changed().await.expect("watch notifies"); - } - }) - .await - .expect("status must reach a terminal state") -} - -pub(super) async fn wait_for_running_phase( - watch: &mut watch::Receiver, - phase: KwPhase, -) -> KwJobStatus { - time::timeout(Duration::from_secs(10), async { - loop { - let status = watch.borrow().job.clone(); - if matches!( - status, - KwJobStatus::Running { phase: running, .. } if running == phase - ) { - return status; - } - watch.changed().await.expect("watch notifies"); - } - }) - .await - .expect("status must reach the expected running phase") -} - #[tokio::test] async fn start_build_replies_immediately_and_runs_in_background() { let (handle, process, log_dir) = spawn_job_actor("start-immediate"); diff --git a/src/kw/argv.rs b/src/kw/argv.rs index f0cafad..b64a307 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -287,11 +287,22 @@ impl KwArgvService { #[cfg(test)] mod tests { - use super::*; - fn extras(tokens: &[&str]) -> Vec { - tokens.iter().map(|token| token.to_string()).collect() + mod helpers { + use super::super::*; + + pub(super) fn extras(tokens: &[&str]) -> Vec { + tokens.iter().map(|token| token.to_string()).collect() + } + + pub(super) const ENDPOINT: &str = "root@lima-ph-dut.internal:22"; + + pub(super) fn deploy(extra: &[&str]) -> Vec { + KwArgvService::build_deploy_argv(ENDPOINT, false, true, &extras(extra)) + } } + use super::*; + use helpers::*; #[test] fn build_argv_without_extras_is_the_base_command() { @@ -405,12 +416,6 @@ mod tests { ); } - const ENDPOINT: &str = "root@lima-ph-dut.internal:22"; - - fn deploy(extra: &[&str]) -> Vec { - KwArgvService::build_deploy_argv(ENDPOINT, false, true, &extras(extra)) - } - #[test] fn deploy_argv_without_extras_is_remote_no_reboot_force() { assert_eq!( diff --git a/src/kw/history.rs b/src/kw/history.rs index 236ff9e..1061670 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -173,78 +173,89 @@ impl KwHistoryStore for FileKwHistoryStore { #[cfg(test)] mod tests { - use std::fs; - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - - use crate::infrastructure::file_system::OsFileSystem; - use super::*; - use std::env; - use std::process; - - static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - - fn tmp_dir(test_name: &str) -> PathBuf { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = env::temp_dir().join(format!( - "patch-hub-kw-history-{}-{}-{}", - test_name, - process::id(), - n - )); - // A leftover from a failed previous run (pid reuse + counter reset) - // must not poison this one. - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - dir - } + mod helpers { + use super::super::*; + use crate::infrastructure::file_system::OsFileSystem; + use std::env; + use std::fs; + use std::path::PathBuf; + use std::process; + use std::sync::atomic::{AtomicU64, Ordering}; + + pub(super) static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + + pub(super) fn tmp_dir(test_name: &str) -> PathBuf { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let dir = env::temp_dir().join(format!( + "patch-hub-kw-history-{}-{}-{}", + test_name, + process::id(), + n + )); + // A leftover from a failed previous run (pid reuse + counter reset) + // must not poison this one. + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("dir creates"); + dir + } - fn store_at(dir: &Path) -> FileKwHistoryStore { - FileKwHistoryStore::new( - Arc::new(OsFileSystem), - dir.to_str().expect("path is utf-8").to_string(), - ) - } + pub(super) fn store_at(dir: &Path) -> FileKwHistoryStore { + FileKwHistoryStore::new( + Arc::new(OsFileSystem), + dir.to_str().expect("path is utf-8").to_string(), + ) + } - fn record(message_id: &str, kernel_tree_id: &str, branch: &str) -> KwApplyRecord { - KwApplyRecord { - message_id: message_id.to_string(), - kernel_tree_id: kernel_tree_id.to_string(), - tree_path: format!("/home/user/{kernel_tree_id}"), - applied_branch: branch.to_string(), - base_branch: "master".to_string(), - applied_at: "2026-08-01T17:30:00Z".to_string(), + pub(super) fn record( + message_id: &str, + kernel_tree_id: &str, + branch: &str, + ) -> KwApplyRecord { + KwApplyRecord { + message_id: message_id.to_string(), + kernel_tree_id: kernel_tree_id.to_string(), + tree_path: format!("/home/user/{kernel_tree_id}"), + applied_branch: branch.to_string(), + base_branch: "master".to_string(), + applied_at: "2026-08-01T17:30:00Z".to_string(), + } } - } - fn record_at( - message_id: &str, - kernel_tree_id: &str, - branch: &str, - applied_at: &str, - ) -> KwApplyRecord { - KwApplyRecord { - applied_at: applied_at.to_string(), - ..record(message_id, kernel_tree_id, branch) + pub(super) fn record_at( + message_id: &str, + kernel_tree_id: &str, + branch: &str, + applied_at: &str, + ) -> KwApplyRecord { + KwApplyRecord { + applied_at: applied_at.to_string(), + ..record(message_id, kernel_tree_id, branch) + } } - } - fn build(kernel_tree_id: &str, branch: &str, built_at: &str) -> KwBuildRecord { - KwBuildRecord { - kernel_tree_id: kernel_tree_id.to_string(), - tree_path: format!("/home/user/{kernel_tree_id}"), - message_id: None, - branch: branch.to_string(), - arch: Some("x86".to_string()), - image_path: Some(format!("/home/user/{kernel_tree_id}/arch/x86/boot/bzImage")), - output_dir: None, - kernelrelease: Some("6.17.0".to_string()), - log_path: "/home/user/.cache/patch_hub/kw_logs/build-1.log".to_string(), - built_at: built_at.to_string(), - success: true, + pub(super) fn build(kernel_tree_id: &str, branch: &str, built_at: &str) -> KwBuildRecord { + KwBuildRecord { + kernel_tree_id: kernel_tree_id.to_string(), + tree_path: format!("/home/user/{kernel_tree_id}"), + message_id: None, + branch: branch.to_string(), + arch: Some("x86".to_string()), + image_path: Some(format!("/home/user/{kernel_tree_id}/arch/x86/boot/bzImage")), + output_dir: None, + kernelrelease: Some("6.17.0".to_string()), + log_path: "/home/user/.cache/patch_hub/kw_logs/build-1.log".to_string(), + built_at: built_at.to_string(), + success: true, + } } } + use helpers::*; + use std::fs; + + use crate::infrastructure::file_system::OsFileSystem; + + use super::*; #[test] fn record_and_read_round_trip() { diff --git a/src/kw/log_scan.rs b/src/kw/log_scan.rs index 9cfb5d2..c0a66a1 100644 --- a/src/kw/log_scan.rs +++ b/src/kw/log_scan.rs @@ -112,76 +112,80 @@ impl LogScanService { #[cfg(test)] mod tests { - use super::*; - /// `kw build` under an env `O=` with in-tree leftovers in the source - /// tree. - const UNCLEAN_TREE_BUILD_LOG: &str = "\ -/boot/config-6.8.0-138-generic:883:warning: symbol value '0' invalid for BASE_SMALL - SYNC include/config/auto.conf.cmd -*** -*** The source tree is not clean, please run 'make ARCH=arm64 mrproper' -*** in /opt/ph-lab/work/linux -*** -make[3]: *** [/opt/ph-lab/work/linux/Makefile:709: outputmakefile] Error 1 -make[2]: *** [/opt/ph-lab/work/linux/Makefile:906: include/config/auto.conf.cmd] Error 2 -make[2]: *** [include/config/auto.conf.cmd] Deleting file 'include/generated/rustc_cfg' -make[2]: *** [include/config/auto.conf.cmd] Deleting file 'include/generated/autoconf.h' -make[1]: *** [/opt/ph-lab/work/linux/Makefile:248: __sub-make] Error 2 -make[1]: Leaving directory '/opt/ph-lab/work/out' -"; + mod helpers { - /// A compile error that stops the build. - const COMPILE_ERROR_BUILD_LOG: &str = "\ - CC mm/filemap.o - AS arch/arm64/kernel/entry.o -/opt/ph-lab/work/linux/init/main.c:1691:2: error: #error M4 broken-build fixture - 1691 | #error M4 broken-build fixture + /// `kw build` under an env `O=` with in-tree leftovers in the source + /// tree. + pub(super) const UNCLEAN_TREE_BUILD_LOG: &str = "\ + /boot/config-6.8.0-138-generic:883:warning: symbol value '0' invalid for BASE_SMALL + SYNC include/config/auto.conf.cmd + *** + *** The source tree is not clean, please run 'make ARCH=arm64 mrproper' + *** in /opt/ph-lab/work/linux + *** + make[3]: *** [/opt/ph-lab/work/linux/Makefile:709: outputmakefile] Error 1 + make[2]: *** [/opt/ph-lab/work/linux/Makefile:906: include/config/auto.conf.cmd] Error 2 + make[2]: *** [include/config/auto.conf.cmd] Deleting file 'include/generated/rustc_cfg' + make[2]: *** [include/config/auto.conf.cmd] Deleting file 'include/generated/autoconf.h' + make[1]: *** [/opt/ph-lab/work/linux/Makefile:248: __sub-make] Error 2 + make[1]: Leaving directory '/opt/ph-lab/work/out' + "; + + /// A compile error that stops the build. + pub(super) const COMPILE_ERROR_BUILD_LOG: &str = "\ + CC mm/filemap.o + AS arch/arm64/kernel/entry.o + /opt/ph-lab/work/linux/init/main.c:1691:2: error: #error M4 broken-build fixture + 1691 | #error M4 broken-build fixture | ^~~~~ -make[4]: *** [/opt/ph-lab/work/linux/scripts/Makefile.build:289: init/main.o] Error 1 -make[3]: *** [/opt/ph-lab/work/linux/scripts/Makefile.build:549: init] Error 2 -make[3]: *** Waiting for unfinished jobs.... - CC kernel/exec_domain.o -make: *** [Makefile:248: __sub-make] Error 2 -"; + make[4]: *** [/opt/ph-lab/work/linux/scripts/Makefile.build:289: init/main.o] Error 1 + make[3]: *** [/opt/ph-lab/work/linux/scripts/Makefile.build:549: init] Error 2 + make[3]: *** Waiting for unfinished jobs.... + CC kernel/exec_domain.o + make: *** [Makefile:248: __sub-make] Error 2 + "; - /// tinyconfig deploy: kw exits 0 although initramfs generation failed - /// and GRUB never listed the kernel. - const TINYCONFIG_DEPLOY_LOG: &str = "\ -cp /tmp/kw/kw_pkg/Image-7.2.0-rc6+ /boot/ -generate_debian_temporary_root_file_system VERBOSE 7.2.0-rc6+ remote GRUB -update-initramfs -c -k 7.2.0-rc6+ -update-initramfs: Generating /boot/initrd.img-7.2.0-rc6+ -W: zstd compression (CONFIG_RD_ZSTD) not supported by kernel, using gzip -E: gzip compression (CONFIG_RD_GZIP) not supported by kernel -update-initramfs: failed for /boot/initrd.img-7.2.0-rc6+ with 1. -update-grub -Sourcing file `/etc/default/grub' -Generating grub configuration file ... -Found linux image: /boot/vmlinuz-6.8.0-137-generic -Found initrd image: /boot/initrd.img-6.8.0-137-generic -Found linux image: /boot/vmlinuz-6.8.0-136-generic -Found initrd image: /boot/initrd.img-6.8.0-136-generic -Adding boot menu entry for UEFI Firmware Settings ... -done -"; + /// tinyconfig deploy: kw exits 0 although initramfs generation failed + /// and GRUB never listed the kernel. + pub(super) const TINYCONFIG_DEPLOY_LOG: &str = "\ + cp /tmp/kw/kw_pkg/Image-7.2.0-rc6+ /boot/ + generate_debian_temporary_root_file_system VERBOSE 7.2.0-rc6+ remote GRUB + update-initramfs -c -k 7.2.0-rc6+ + update-initramfs: Generating /boot/initrd.img-7.2.0-rc6+ + W: zstd compression (CONFIG_RD_ZSTD) not supported by kernel, using gzip + E: gzip compression (CONFIG_RD_GZIP) not supported by kernel + update-initramfs: failed for /boot/initrd.img-7.2.0-rc6+ with 1. + update-grub + Sourcing file `/etc/default/grub' + Generating grub configuration file ... + Found linux image: /boot/vmlinuz-6.8.0-137-generic + Found initrd image: /boot/initrd.img-6.8.0-137-generic + Found linux image: /boot/vmlinuz-6.8.0-136-generic + Found initrd image: /boot/initrd.img-6.8.0-136-generic + Adding boot menu entry for UEFI Firmware Settings ... + done + "; - /// A deploy where initramfs generation and GRUB both succeed. - const CLEAN_DEPLOY_LOG: &str = "\ -* Preparing modules -cp: cannot stat '/home/lima.guest/.cache/kw/envs/L29wdC9waC1sYWIvd29yay9saW51eA==/ph-boot/arch/arm64/boot/dts/*.dtb': No such file or directory -* Sending kernel package (7.2.0-rc6-phboot-g2a475abe5df2.kw.tar) to the remote -update-initramfs: Generating /boot/initrd.img-7.2.0-rc6-phboot-g2a475abe5df2 -Ignoring old or unknown version 7.2.0-rc6-phboot-g2a475abe5df2 (latest is 6.8.0-137-generic) -Generating grub configuration file ... -Found linux image: /boot/vmlinuz-6.8.0-137-generic -Found initrd image: /boot/initrd.img-6.8.0-137-generic -Found linux image: /boot/Image-7.2.0-rc6-phboot-g2a475abe5df2 -Found initrd image: /boot/initrd.img-7.2.0-rc6-phboot-g2a475abe5df2 -Found linux image: /boot/Image-7.2.0-rc6-phboot+ -Found linux image: /boot/Image-7.2.0-rc6+ -done -"; + /// A deploy where initramfs generation and GRUB both succeed. + pub(super) const CLEAN_DEPLOY_LOG: &str = "\ + * Preparing modules + cp: cannot stat '/home/lima.guest/.cache/kw/envs/L29wdC9waC1sYWIvd29yay9saW51eA==/ph-boot/arch/arm64/boot/dts/*.dtb': No such file or directory + * Sending kernel package (7.2.0-rc6-phboot-g2a475abe5df2.kw.tar) to the remote + update-initramfs: Generating /boot/initrd.img-7.2.0-rc6-phboot-g2a475abe5df2 + Ignoring old or unknown version 7.2.0-rc6-phboot-g2a475abe5df2 (latest is 6.8.0-137-generic) + Generating grub configuration file ... + Found linux image: /boot/vmlinuz-6.8.0-137-generic + Found initrd image: /boot/initrd.img-6.8.0-137-generic + Found linux image: /boot/Image-7.2.0-rc6-phboot-g2a475abe5df2 + Found initrd image: /boot/initrd.img-7.2.0-rc6-phboot-g2a475abe5df2 + Found linux image: /boot/Image-7.2.0-rc6-phboot+ + Found linux image: /boot/Image-7.2.0-rc6+ + done + "; + } + use super::*; + use helpers::*; #[test] fn first_error_names_the_kbuild_banner_not_the_make_cascade() { diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index aed40ee..5a07814 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -500,105 +500,160 @@ impl ReadinessService { #[cfg(test)] mod tests { + + mod helpers { + use std::{ + fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, SystemTime}, + }; + + use crate::config::KernelTree; + use crate::infrastructure::shell::ShellOutput; + + use crate::kw::models::history::KwBuildRecord; + + use std::env; + + use std::process; + + pub(super) static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + + pub(super) struct TempDir(PathBuf); + + impl TempDir { + pub(super) fn new(test_name: &str) -> Self { + let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); + let dir = env::temp_dir().join(format!( + "patch-hub-kw-readiness-{}-{}-{}", + test_name, + process::id(), + n + )); + // A leftover from a failed previous run must not poison this one. + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("dir creates"); + Self(dir) + } + + pub(super) fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + pub(super) const KERNEL_ROOT_FILES: [&str; 6] = [ + "COPYING", + "CREDITS", + "Kbuild", + "Makefile", + "README", + "MAINTAINERS", + ]; + pub(super) const KERNEL_ROOT_DIRS: [&str; 10] = [ + "Documentation", + "arch", + "include", + "drivers", + "fs", + "init", + "ipc", + "kernel", + "lib", + "scripts", + ]; + /// Creates the exact file/dir set kw's `is_kernel_root` expects. + pub(super) fn make_kernel_root(dir: &Path) { + for file in KERNEL_ROOT_FILES { + fs::write(dir.join(file), "").expect("file writes"); + } + for sub in KERNEL_ROOT_DIRS { + fs::create_dir(dir.join(sub)).expect("dir creates"); + } + } + + /// A kernel root with `.kw/` and an in-tree `.config`: the fully ready + /// fixture most probes start from. + pub(super) fn make_ready_tree(test_name: &str) -> TempDir { + let dir = TempDir::new(test_name); + make_kernel_root(dir.path()); + fs::create_dir(dir.path().join(".kw")).expect("dir creates"); + fs::write(dir.path().join(".config"), "").expect("file writes"); + dir + } + + /// Creates a file whose mtime is `mtime_secs` seconds after the epoch, + /// so newest-wins ordering is fully deterministic. + pub(super) fn write_file_with_mtime(path: &Path, mtime_secs: u64) { + let file = fs::File::create(path).expect("file creates"); + file.set_times( + fs::FileTimes::new() + .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs)), + ) + .expect("mtime sets"); + } + + pub(super) fn shell_output(stdout: &str, success: bool) -> ShellOutput { + ShellOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + success, + } + } + + pub(super) fn kernel_tree(path: &Path) -> KernelTree { + serde_json::from_value(serde_json::json!({ + "path": path.to_str().expect("path is utf-8"), + "branch": "master" + })) + .expect("json parses") + } + + pub(super) fn built_record(tree: &Path, branch: &str) -> KwBuildRecord { + KwBuildRecord { + kernel_tree_id: "mainline".to_string(), + tree_path: tree.to_str().expect("path is utf-8").to_string(), + message_id: None, + branch: branch.to_string(), + arch: Some("x86".to_string()), + image_path: None, + output_dir: None, + kernelrelease: None, + log_path: String::new(), + built_at: "2026-08-01T18:10:00Z".to_string(), + success: true, + } + } + } + use helpers::*; use std::{ fs, path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, - time::{Duration, SystemTime}, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; - use crate::config::KernelTree; use crate::infrastructure::{ env::MockEnvTrait, file_system::{FileSystemError, MockFileSystemTrait, OsFileSystem}, - shell::{MockShellTrait, ShellOutput}, + shell::MockShellTrait, }; + use crate::kw::history::{FileKwHistoryStore, KwHistoryStore}; use crate::kw::models::readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}; use crate::kw::models::remote::RemoteRefusal; - use crate::kw::{ - history::{FileKwHistoryStore, KwHistoryStore}, - models::history::KwBuildRecord, - }; use super::*; use std::env; use std::io; - use std::process; - use std::sync::Arc; - - static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - - struct TempDir(PathBuf); - - impl TempDir { - fn new(test_name: &str) -> Self { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = env::temp_dir().join(format!( - "patch-hub-kw-readiness-{}-{}-{}", - test_name, - process::id(), - n - )); - // A leftover from a failed previous run must not poison this one. - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - Self(dir) - } - - fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - const KERNEL_ROOT_FILES: [&str; 6] = [ - "COPYING", - "CREDITS", - "Kbuild", - "Makefile", - "README", - "MAINTAINERS", - ]; - const KERNEL_ROOT_DIRS: [&str; 10] = [ - "Documentation", - "arch", - "include", - "drivers", - "fs", - "init", - "ipc", - "kernel", - "lib", - "scripts", - ]; - - /// Creates the exact file/dir set kw's `is_kernel_root` expects. - fn make_kernel_root(dir: &Path) { - for file in KERNEL_ROOT_FILES { - fs::write(dir.join(file), "").expect("file writes"); - } - for sub in KERNEL_ROOT_DIRS { - fs::create_dir(dir.join(sub)).expect("dir creates"); - } - } - /// A kernel root with `.kw/` and an in-tree `.config`: the fully ready - /// fixture most probes start from. - fn make_ready_tree(test_name: &str) -> TempDir { - let dir = TempDir::new(test_name); - make_kernel_root(dir.path()); - fs::create_dir(dir.path().join(".kw")).expect("dir creates"); - fs::write(dir.path().join(".config"), "").expect("file writes"); - dir - } + use std::sync::Arc; #[test] fn parse_kw_config_mirrors_kw_semantics() { @@ -826,17 +881,6 @@ last_line_without_newline=yes"; ); } - /// Creates a file whose mtime is `mtime_secs` seconds after the epoch, - /// so newest-wins ordering is fully deterministic. - fn write_file_with_mtime(path: &Path, mtime_secs: u64) { - let file = fs::File::create(path).expect("file creates"); - file.set_times( - fs::FileTimes::new() - .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs)), - ) - .expect("mtime sets"); - } - #[test] fn resolve_output_dir_without_env_file_is_inactive() { let dir = make_ready_tree("env-inactive"); @@ -1142,38 +1186,6 @@ last_line_without_newline=yes"; ); } - fn shell_output(stdout: &str, success: bool) -> ShellOutput { - ShellOutput { - stdout: stdout.as_bytes().to_vec(), - stderr: Vec::new(), - success, - } - } - - fn kernel_tree(path: &Path) -> KernelTree { - serde_json::from_value(serde_json::json!({ - "path": path.to_str().expect("path is utf-8"), - "branch": "master" - })) - .expect("json parses") - } - - fn built_record(tree: &Path, branch: &str) -> KwBuildRecord { - KwBuildRecord { - kernel_tree_id: "mainline".to_string(), - tree_path: tree.to_str().expect("path is utf-8").to_string(), - message_id: None, - branch: branch.to_string(), - arch: Some("x86".to_string()), - image_path: None, - output_dir: None, - kernelrelease: None, - log_path: String::new(), - built_at: "2026-08-01T18:10:00Z".to_string(), - success: true, - } - } - #[test] fn kw_probe_missing_binary_never_spawns() { let mut env = MockEnvTrait::new(); diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 8fbeaf7..59d997e 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -205,60 +205,123 @@ impl KwRemote { #[cfg(test)] mod tests { - use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::Arc, - }; - use crate::infrastructure::{ - env::{EnvError, MockEnvTrait}, - file_system::{FileSystemError, MockFileSystemTrait}, - }; - - use super::*; - use crate::kw::models::remote::RemoteRefusal; - use std::env; - use std::io; + mod helpers { + use super::super::*; + use crate::infrastructure::{ + env::{EnvError, MockEnvTrait}, + file_system::{FileSystemError, MockFileSystemTrait}, + }; + use crate::kw::models::remote::RemoteRefusal; + use std::env; + use std::io; + use std::{collections::HashMap, path::PathBuf, sync::Arc}; + + /// Example remote.config: two hosts, default on the second, fields indented. + pub(super) const SAMPLE_REMOTE_CONFIG: &str = "\ + #kw-default=arch-test + Host steamos + Hostname steamdeck + Port 8888 + User jozzi + Host arch-test + Hostname arch-tm + Port 22 + User abc + "; + + /// Lab deploy-smoke file: IdentityFile must not break parsing, and + /// User root must land on the endpoint. + pub(super) const LAB_FIXTURE: &str = "\ + #kw-default=ph-dut + Host ph-dut + Hostname lima-ph-dut.internal + Port 22 + User root + IdentityFile /opt/ph-lab/keys/lab_ed25519 + "; + + pub(super) fn choose(content: &str) -> Result { + RemoteConfigService::select_deploy_remote(&RemoteConfigService::parse_remote_config( + content, + )) + } - /// Example remote.config: two hosts, default on the second, fields indented. - const SAMPLE_REMOTE_CONFIG: &str = "\ -#kw-default=arch-test -Host steamos - Hostname steamdeck - Port 8888 - User jozzi -Host arch-test - Hostname arch-tm - Port 22 - User abc -"; + pub(super) fn remote( + name: &str, + hostname: &str, + port: u16, + user: Option<&str>, + ) -> KwRemote { + KwRemote { + name: name.to_string(), + hostname: hostname.to_string(), + port, + user: user.map(str::to_string), + } + } - /// Lab deploy-smoke file: IdentityFile must not break parsing, and - /// User root must land on the endpoint. - const LAB_FIXTURE: &str = "\ -#kw-default=ph-dut -Host ph-dut - Hostname lima-ph-dut.internal - Port 22 - User root - IdentityFile /opt/ph-lab/keys/lab_ed25519 -"; + pub(super) fn fs_with_files(files: &[(&str, &str)]) -> MockFileSystemTrait { + let map = files + .iter() + .map(|(path, content)| (PathBuf::from(path), content.to_string())) + .collect::>(); + let is_file = Arc::new(map.clone()); + let read = Arc::new(map); + let mut fs = MockFileSystemTrait::new(); + fs.expect_is_file() + .withf(|path| { + path == std::path::Path::new("/home/user/.config/kw/remote.config") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/xdg/kw/remote.config") + }) + .times(1..=2) + .returning(move |path| is_file.contains_key(path)); + fs.expect_read_to_string() + .withf(|path| { + path == std::path::Path::new("/home/user/.config/kw/remote.config") + || path == std::path::Path::new("/home/user/linux/.kw/remote.config") + || path == std::path::Path::new("/kernel/.kw/remote.config") + || path == std::path::Path::new("/xdg/kw/remote.config") + }) + .times(0..=1) + .returning(move |path| { + read.get(path).cloned().ok_or_else(|| { + FileSystemError::IoError(io::Error::new(io::ErrorKind::NotFound, "missing")) + }) + }); + fs + } - fn choose(content: &str) -> Result { - RemoteConfigService::select_deploy_remote(&RemoteConfigService::parse_remote_config( - content, - )) - } + pub(super) fn env_with(xdg: Option<&str>, home: Option<&str>) -> MockEnvTrait { + let xdg = xdg.map(str::to_string); + let home = home.map(str::to_string); + let mut env = MockEnvTrait::new(); + env.expect_var() + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(0..=2) + .returning(move |key| match key { + "XDG_CONFIG_HOME" => xdg.clone().ok_or_else(missing_var), + "HOME" => home.clone().ok_or_else(missing_var), + _ => Err(missing_var()), + }); + env + } - fn remote(name: &str, hostname: &str, port: u16, user: Option<&str>) -> KwRemote { - KwRemote { - name: name.to_string(), - hostname: hostname.to_string(), - port, - user: user.map(str::to_string), + pub(super) fn missing_var() -> EnvError { + env::VarError::NotPresent.into() } } + use helpers::*; + use std::path::Path; + + use crate::infrastructure::file_system::{FileSystemError, MockFileSystemTrait}; + + use super::*; + use crate::kw::models::remote::RemoteRefusal; + + use std::io; #[test] fn plan_fixture_picks_the_default_host() { @@ -473,58 +536,6 @@ Host dut extra .contains("gone")); } - fn fs_with_files(files: &[(&str, &str)]) -> MockFileSystemTrait { - let map = files - .iter() - .map(|(path, content)| (PathBuf::from(path), content.to_string())) - .collect::>(); - let is_file = Arc::new(map.clone()); - let read = Arc::new(map); - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_file() - .withf(|path| { - path == std::path::Path::new("/home/user/.config/kw/remote.config") - || path == std::path::Path::new("/home/user/linux/.kw/remote.config") - || path == std::path::Path::new("/kernel/.kw/remote.config") - || path == std::path::Path::new("/xdg/kw/remote.config") - }) - .times(1..=2) - .returning(move |path| is_file.contains_key(path)); - fs.expect_read_to_string() - .withf(|path| { - path == std::path::Path::new("/home/user/.config/kw/remote.config") - || path == std::path::Path::new("/home/user/linux/.kw/remote.config") - || path == std::path::Path::new("/kernel/.kw/remote.config") - || path == std::path::Path::new("/xdg/kw/remote.config") - }) - .times(0..=1) - .returning(move |path| { - read.get(path).cloned().ok_or_else(|| { - FileSystemError::IoError(io::Error::new(io::ErrorKind::NotFound, "missing")) - }) - }); - fs - } - - fn env_with(xdg: Option<&str>, home: Option<&str>) -> MockEnvTrait { - let xdg = xdg.map(str::to_string); - let home = home.map(str::to_string); - let mut env = MockEnvTrait::new(); - env.expect_var() - .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) - .times(0..=2) - .returning(move |key| match key { - "XDG_CONFIG_HOME" => xdg.clone().ok_or_else(missing_var), - "HOME" => home.clone().ok_or_else(missing_var), - _ => Err(missing_var()), - }); - env - } - - fn missing_var() -> EnvError { - env::VarError::NotPresent.into() - } - #[test] fn resolve_prefers_the_tree_remote_config() { let fs = fs_with_files(&[ diff --git a/src/kw/status.rs b/src/kw/status.rs index 6747c01..d52dc35 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -122,22 +122,28 @@ impl KwJobStatus { #[cfg(test)] mod tests { - use std::path::PathBuf; - use super::*; + mod helpers { + use super::super::*; + use std::path::PathBuf; - fn running(branch: &str) -> KwStatusSnapshot { - KwStatusSnapshot { - job: KwJobStatus::Running { - kind: KwJobKind::Build, - phase: KwPhase::Building, - kernel_tree_id: "mainline".to_string(), - branch: branch.to_string(), - log_path: PathBuf::from("/tmp/build.log"), - }, - restore_branch: Some("master".to_string()), + pub(super) fn running(branch: &str) -> KwStatusSnapshot { + KwStatusSnapshot { + job: KwJobStatus::Running { + kind: KwJobKind::Build, + phase: KwPhase::Building, + kernel_tree_id: "mainline".to_string(), + branch: branch.to_string(), + log_path: PathBuf::from("/tmp/build.log"), + }, + restore_branch: Some("master".to_string()), + } } } + use helpers::*; + use std::path::PathBuf; + + use super::*; #[test] fn running_indicator_names_the_phase_and_branch() { diff --git a/src/lore/application/actor.rs b/src/lore/application/actor.rs index d210650..619de51 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -254,48 +254,53 @@ impl LoreApiActor { #[cfg(test)] mod tests { - use std::{collections::HashMap, sync::Arc}; - use crate::{ - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - lore::{ - application::{ - handle::LoreApiHandle, - models::cache::{CacheMode, CacheTtl}, + mod helpers { + use super::super::*; + use crate::{ + infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, + lore::{ + application::{handle::LoreApiHandle, models::cache::CacheTtl}, + infrastructure::{ + http_lore_client::{MockFeedGateway, MockListsGateway, MockPatchHtmlGateway}, + patchset_fetcher::MockPatchsetFetcher, + patchset_parser::MockPatchsetParser, + persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, + }, }, - domain::mailing_list::MailingList, - infrastructure::{ - http_lore_client::{MockFeedGateway, MockListsGateway, MockPatchHtmlGateway}, - patchset_fetcher::MockPatchsetFetcher, - patchset_parser::MockPatchsetParser, - persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, - }, - }, - }; + }; + use std::sync::Arc; - use super::*; + pub(super) fn make_service( + lists_store: MockMailingListsCacheStore, + user_state: MockUserLoreStateStore, + ) -> LoreService { + LoreService::new( + Arc::new(MockListsGateway::new()), + Arc::new(MockFeedGateway::new()), + Arc::new(MockPatchHtmlGateway::new()), + Arc::new(lists_store), + Arc::new(user_state), + Arc::new(MockPatchsetFetcher::new()), + Arc::new(MockPatchsetParser::new()), + Arc::new(MockFileSystemTrait::new()), + Arc::new(MockShellTrait::new()), + CacheTtl::default(), + ) + } - fn make_service( - lists_store: MockMailingListsCacheStore, - user_state: MockUserLoreStateStore, - ) -> LoreService { - LoreService::new( - Arc::new(MockListsGateway::new()), - Arc::new(MockFeedGateway::new()), - Arc::new(MockPatchHtmlGateway::new()), - Arc::new(lists_store), - Arc::new(user_state), - Arc::new(MockPatchsetFetcher::new()), - Arc::new(MockPatchsetParser::new()), - Arc::new(MockFileSystemTrait::new()), - Arc::new(MockShellTrait::new()), - CacheTtl::default(), - ) + pub(super) fn spawn_test_actor(core: LoreService) -> LoreApiHandle { + LoreApiActor::spawn(core) + } } + use helpers::*; + use std::collections::HashMap; - fn spawn_test_actor(core: LoreService) -> LoreApiHandle { - LoreApiActor::spawn(core) - } + use crate::lore::{ + application::models::cache::CacheMode, + domain::mailing_list::MailingList, + infrastructure::persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, + }; #[tokio::test] async fn handle_returns_bootstrap_data_from_actor() { diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index a1ed9c0..01a9102 100644 --- a/src/lore/application/service.rs +++ b/src/lore/application/service.rs @@ -461,7 +461,60 @@ impl LoreService { #[cfg(test)] mod tests { - use std::{fs, sync::Arc}; + + mod helpers { + use super::super::*; + use crate::lore::infrastructure::{ + http_lore_client::{MockFeedGateway, MockListsGateway, MockPatchHtmlGateway}, + patchset_fetcher::MockPatchsetFetcher, + patchset_parser::MockPatchsetParser, + persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, + }; + use crate::{ + infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, + lore::application::models::cache::CacheTtl, + }; + use std::sync::Arc; + + // ── helpers ─────────────────────────────────────────────────────────────── + + pub(super) fn make_service( + lists_gateway: MockListsGateway, + feed_gateway: MockFeedGateway, + patch_html_gateway: MockPatchHtmlGateway, + lists_store: MockMailingListsCacheStore, + user_state: MockUserLoreStateStore, + fetcher: MockPatchsetFetcher, + parser: MockPatchsetParser, + ) -> LoreService { + LoreService::new( + Arc::new(lists_gateway), + Arc::new(feed_gateway), + Arc::new(patch_html_gateway), + Arc::new(lists_store), + Arc::new(user_state), + Arc::new(fetcher), + Arc::new(parser), + Arc::new(MockFileSystemTrait::new()), + Arc::new(MockShellTrait::new()), + CacheTtl::default(), + ) + } + + // ── patchset details cache tests ────────────────────────────────────────── + + pub(super) fn make_patch_for_cache(msg_id: &str) -> Patch { + serde_json::from_value(serde_json::json!({ + "title": "test", + "author": { "name": "T", "email": "t@t.com" }, + "link": { "@href": msg_id }, + "updated": "2023-01-01" + })) + .expect("json parses") + } + } + use helpers::*; + use std::fs; use crate::lore::infrastructure::{ http_lore_client::{ @@ -472,44 +525,15 @@ mod tests { patchset_parser::MockPatchsetParser, persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, }; - use crate::{ - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - lore::{ - application::models::cache::{ - CacheTtl, FeedCacheEntry, MailingListsCacheEntry, PatchsetCacheEntry, - PatchsetCacheKey, - }, - domain::{mailing_list::MailingList, patchset::PatchFeedIndex}, + use crate::lore::{ + application::models::cache::{ + FeedCacheEntry, MailingListsCacheEntry, PatchsetCacheEntry, PatchsetCacheKey, }, + domain::{mailing_list::MailingList, patchset::PatchFeedIndex}, }; use super::*; - // ── helpers ─────────────────────────────────────────────────────────────── - - fn make_service( - lists_gateway: MockListsGateway, - feed_gateway: MockFeedGateway, - patch_html_gateway: MockPatchHtmlGateway, - lists_store: MockMailingListsCacheStore, - user_state: MockUserLoreStateStore, - fetcher: MockPatchsetFetcher, - parser: MockPatchsetParser, - ) -> LoreService { - LoreService::new( - Arc::new(lists_gateway), - Arc::new(feed_gateway), - Arc::new(patch_html_gateway), - Arc::new(lists_store), - Arc::new(user_state), - Arc::new(fetcher), - Arc::new(parser), - Arc::new(MockFileSystemTrait::new()), - Arc::new(MockShellTrait::new()), - CacheTtl::default(), - ) - } - // ── mailing lists cache tests ───────────────────────────────────────────── #[test] @@ -832,18 +856,6 @@ mod tests { assert_eq!(1, patches.len()); } - // ── patchset details cache tests ────────────────────────────────────────── - - fn make_patch_for_cache(msg_id: &str) -> Patch { - serde_json::from_value(serde_json::json!({ - "title": "test", - "author": { "name": "T", "email": "t@t.com" }, - "link": { "@href": msg_id }, - "updated": "2023-01-01" - })) - .expect("json parses") - } - #[test] fn fetch_patchset_details_use_cache_hit() { // Pre-populate the cache; fetcher and parser must NOT be called. diff --git a/src/lore/domain/patchset.rs b/src/lore/domain/patchset.rs index afcb7a5..27b0f98 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -114,15 +114,20 @@ impl PatchFeedIndex { #[cfg(test)] mod tests { - use std::fs; - use super::*; - use crate::lore::infrastructure::parsers::LoreParserService; + mod helpers { + use super::super::*; + use crate::lore::infrastructure::parsers::LoreParserService; + use std::fs; - fn feed_from_file(path: &str) -> PatchFeed { - let xml = fs::read_to_string(path).expect("file reads"); - LoreParserService::parse_patch_feed(&xml).expect("patch feed parses") + pub(super) fn feed_from_file(path: &str) -> PatchFeed { + let xml = fs::read_to_string(path).expect("file reads"); + LoreParserService::parse_patch_feed(&xml).expect("patch feed parses") + } } + use helpers::*; + + use super::*; #[test] fn new_starts_with_empty_state() { diff --git a/src/lore/infrastructure/patchset_fetcher.rs b/src/lore/infrastructure/patchset_fetcher.rs index 7785bbf..237a836 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -104,24 +104,22 @@ impl PatchsetFetcher for B4PatchsetFetcher { #[cfg(test)] mod tests { - use super::*; - use crate::infrastructure::{ - file_system::MockFileSystemTrait, - shell::{MockShellTrait, ShellError, ShellOutput}, - }; - use crate::lore::infrastructure::parsers::LoreParserService; - use std::io; - use std::sync::atomic; - // message_id → "linux-kernel.1234.567-1-john@johnson.com.mbx" - // (trailing slash in href becomes the last dot, then "mbx") - const CACHE_DIR: &str = "/cache"; - const MESSAGE_ID: &str = "https://lore.kernel.org/linux-kernel/1234.567-1-john@johnson.com/"; - const CACHED_FILE: &str = "/cache/linux-kernel.1234.567-1-john@johnson.com.mbx"; + mod helpers { + use super::super::*; + + use crate::lore::infrastructure::parsers::LoreParserService; - fn make_patch_with_message_id(message_id: &str) -> Patch { - let xml = format!( - r#" + // message_id → "linux-kernel.1234.567-1-john@johnson.com.mbx" + // (trailing slash in href becomes the last dot, then "mbx") + pub(super) const CACHE_DIR: &str = "/cache"; + pub(super) const MESSAGE_ID: &str = + "https://lore.kernel.org/linux-kernel/1234.567-1-john@johnson.com/"; + pub(super) const CACHED_FILE: &str = "/cache/linux-kernel.1234.567-1-john@johnson.com.mbx"; + + pub(super) fn make_patch_with_message_id(message_id: &str) -> Patch { + let xml = format!( + r#" some/subsystem: Do this @@ -131,12 +129,22 @@ mod tests { "#, - ); - LoreParserService::parse_patch_feed(&xml) - .expect("patch feed parses") - .patches()[0] - .clone() + ); + LoreParserService::parse_patch_feed(&xml) + .expect("patch feed parses") + .patches()[0] + .clone() + } } + use super::*; + use crate::infrastructure::{ + file_system::MockFileSystemTrait, + shell::{MockShellTrait, ShellError, ShellOutput}, + }; + use helpers::*; + + use std::io; + use std::sync::atomic; #[test] fn download_skips_b4_when_file_already_exists() { diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index e305d97..144be1f 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -198,22 +198,27 @@ impl PatchsetTextService { #[cfg(test)] mod tests { - use std::fs; - use std::sync::Arc; - use crate::infrastructure::file_system::OsFileSystem; + mod helpers { - use super::*; - use std::env; - use std::process; + use super::super::*; + use crate::infrastructure::file_system::OsFileSystem; + use std::sync::Arc; - fn parser() -> MboxPatchsetParser { - MboxPatchsetParser::new(Arc::new(OsFileSystem)) - } + pub(super) fn parser() -> MboxPatchsetParser { + MboxPatchsetParser::new(Arc::new(OsFileSystem)) + } - fn commands_eq(cmd1: &ShellCommand, cmd2: &ShellCommand) -> bool { - cmd1.program == cmd2.program && cmd1.args == cmd2.args + pub(super) fn commands_eq(cmd1: &ShellCommand, cmd2: &ShellCommand) -> bool { + cmd1.program == cmd2.program && cmd1.args == cmd2.args + } } + use helpers::*; + use std::fs; + + use super::*; + use std::env; + use std::process; #[test] fn split_patchset_returns_error_on_missing_path() { diff --git a/src/lore/infrastructure/persistence.rs b/src/lore/infrastructure/persistence.rs index 24bda54..c6b10b9 100644 --- a/src/lore/infrastructure/persistence.rs +++ b/src/lore/infrastructure/persistence.rs @@ -103,45 +103,54 @@ impl UserLoreStateStore for FileLorePersistence { #[cfg(test)] mod tests { + + mod helpers { + + use crate::infrastructure::file_system::OsFileSystem; + use std::env; + use std::fs; + use std::sync::Arc; + + use super::super::*; + use std::path::PathBuf; + use std::process; + + pub(super) fn tmp_dir(test_name: &str) -> PathBuf { + let dir = env::temp_dir().join(format!( + "patch-hub-persistence-{}-{}", + test_name, + process::id() + )); + fs::create_dir_all(&dir).expect("dir creates"); + dir + } + + pub(super) fn make_persistence(dir: &Path) -> FileLorePersistence { + FileLorePersistence::new( + Arc::new(OsFileSystem), + dir.join("lists.json") + .to_str() + .expect("path is utf-8") + .to_string(), + dir.join("bookmarked.json") + .to_str() + .expect("path is utf-8") + .to_string(), + dir.join("reviewed.json") + .to_str() + .expect("path is utf-8") + .to_string(), + ) + } + } + use helpers::*; use std::collections::{HashMap, HashSet}; - use std::env; + use std::fs; - use std::sync::Arc; - use crate::infrastructure::file_system::OsFileSystem; use crate::lore::domain::mailing_list::MailingList; use super::*; - use std::path::PathBuf; - use std::process; - - fn tmp_dir(test_name: &str) -> PathBuf { - let dir = env::temp_dir().join(format!( - "patch-hub-persistence-{}-{}", - test_name, - process::id() - )); - fs::create_dir_all(&dir).expect("dir creates"); - dir - } - - fn make_persistence(dir: &Path) -> FileLorePersistence { - FileLorePersistence::new( - Arc::new(OsFileSystem), - dir.join("lists.json") - .to_str() - .expect("path is utf-8") - .to_string(), - dir.join("bookmarked.json") - .to_str() - .expect("path is utf-8") - .to_string(), - dir.join("reviewed.json") - .to_str() - .expect("path is utf-8") - .to_string(), - ) - } #[test] fn available_lists_round_trip() { diff --git a/src/render/actor.rs b/src/render/actor.rs index 868808a..c2ce3b3 100644 --- a/src/render/actor.rs +++ b/src/render/actor.rs @@ -108,20 +108,23 @@ impl RenderActor { #[cfg(test)] mod tests { - use std::sync::Arc; + + mod helpers { + use super::super::*; + use crate::{infrastructure::shell::OsShell, render::ShellRenderService}; + use std::sync::Arc; + + pub(super) fn spawn_test_actor() -> RenderHandle { + RenderActor::spawn(Box::new(ShellRenderService::new(Arc::new(OsShell)))) + } + } + use helpers::*; use crate::{ - infrastructure::shell::OsShell, - render::{RenderPatchsetRequest, ShellRenderService}, + render::RenderPatchsetRequest, render_prefs::{CoverRenderer, PatchRenderer}, }; - use super::*; - - fn spawn_test_actor() -> RenderHandle { - RenderActor::spawn(Box::new(ShellRenderService::new(Arc::new(OsShell)))) - } - #[tokio::test] async fn render_patchset_preview_returns_one_entry_per_patch() { let handle = spawn_test_actor(); diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index c661f34..d5107ae 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -176,6 +176,17 @@ impl TerminalActor { #[cfg(test)] mod tests { + + mod helpers { + + use super::super::*; + use crate::terminal::session::MockTerminalSessionApi; + + pub(super) fn spawn_test_actor(session: MockTerminalSessionApi) -> TerminalHandle { + TerminalActor::spawn(Box::new(session)) + } + } + use helpers::*; use std::time::Duration; use ratatui::crossterm::event::KeyCode; @@ -184,10 +195,6 @@ mod tests { use super::*; - fn spawn_test_actor(session: MockTerminalSessionApi) -> TerminalHandle { - TerminalActor::spawn(Box::new(session)) - } - #[tokio::test] async fn draw_returns_ok_from_actor() { let mut session = MockTerminalSessionApi::new(); diff --git a/src/ui/actor.rs b/src/ui/actor.rs index 3520bf9..f5b0016 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -79,33 +79,40 @@ impl UiActor { #[cfg(test)] mod tests { - use tokio::sync::mpsc; - use crate::{ - app::models::view_model::{ - AppViewModel, MailingListSelectionViewModel, ScreenViewModel, TargetListStatus, - }, - ui::{errors::UiError, handle::UiHandle}, - }; - - use super::{UiActor, DEFAULT_UI_CHANNEL_SIZE}; - - fn minimal_vm() -> AppViewModel { - AppViewModel { - screen: ScreenViewModel::MailingListSelection(MailingListSelectionViewModel { - entries: vec![], - highlighted_index: 0, - target_list: String::new(), - target_list_status: TargetListStatus::Empty, - }), - popup: None, - kw_running: None, + mod helpers { + + use super::super::UiActor; + use crate::{ + app::models::view_model::{ + AppViewModel, MailingListSelectionViewModel, ScreenViewModel, TargetListStatus, + }, + ui::handle::UiHandle, + }; + + pub(super) fn minimal_vm() -> AppViewModel { + AppViewModel { + screen: ScreenViewModel::MailingListSelection(MailingListSelectionViewModel { + entries: vec![], + highlighted_index: 0, + target_list: String::new(), + target_list_status: TargetListStatus::Empty, + }), + popup: None, + kw_running: None, + } } - } - fn spawn_test_actor() -> UiHandle { - UiActor::spawn() + pub(super) fn spawn_test_actor() -> UiHandle { + UiActor::spawn() + } } + use helpers::*; + use tokio::sync::mpsc; + + use crate::ui::{errors::UiError, handle::UiHandle}; + + use super::DEFAULT_UI_CHANNEL_SIZE; #[tokio::test] async fn build_scene_returns_ok_for_valid_view_model() { diff --git a/src/ui/core.rs b/src/ui/core.rs index fcb8086..b682e37 100644 --- a/src/ui/core.rs +++ b/src/ui/core.rs @@ -113,35 +113,38 @@ impl Default for UiCore { #[cfg(test)] mod tests { - use crate::app::models::view_model::{ - AppViewModel, MailingListSelectionViewModel, ScreenViewModel, TargetListStatus, - }; - use super::UiCore; + mod helpers { + use super::super::UiCore; + use crate::app::models::view_model::{ + AppViewModel, MailingListSelectionViewModel, ScreenViewModel, TargetListStatus, + }; - fn mailing_list_vm(kw_running: Option) -> AppViewModel { - AppViewModel { - screen: ScreenViewModel::MailingListSelection(MailingListSelectionViewModel { - entries: vec![], - highlighted_index: 0, - target_list: String::new(), - target_list_status: TargetListStatus::Empty, - }), - popup: None, - kw_running, + pub(super) fn mailing_list_vm(kw_running: Option) -> AppViewModel { + AppViewModel { + screen: ScreenViewModel::MailingListSelection(MailingListSelectionViewModel { + entries: vec![], + highlighted_index: 0, + target_list: String::new(), + target_list_status: TargetListStatus::Empty, + }), + popup: None, + kw_running, + } } - } - fn nav_text(vm: AppViewModel) -> String { - UiCore::new() - .build_scene(&vm) - .expect("scene builds") - .navigation - .mode_spans - .into_iter() - .map(|span| span.content.to_string()) - .collect() + pub(super) fn nav_text(vm: AppViewModel) -> String { + UiCore::new() + .build_scene(&vm) + .expect("scene builds") + .navigation + .mode_spans + .into_iter() + .map(|span| span.content.to_string()) + .collect() + } } + use helpers::*; #[test] fn running_indicator_is_appended_to_the_nav_bar() { diff --git a/src/ui/screens/edit_config.rs b/src/ui/screens/edit_config.rs index 4956e92..0843130 100644 --- a/src/ui/screens/edit_config.rs +++ b/src/ui/screens/edit_config.rs @@ -96,16 +96,24 @@ impl EditConfigPainter { #[cfg(test)] mod tests { - use super::*; - use crate::app::models::view_model::EditConfigViewModel; - fn vm(is_editing_mode: bool, editing_tree_selector: bool) -> EditConfigViewModel { - EditConfigViewModel { - entries: vec![], - is_editing_mode, - editing_tree_selector, + mod helpers { + + use crate::app::models::view_model::EditConfigViewModel; + + pub(super) fn vm( + is_editing_mode: bool, + editing_tree_selector: bool, + ) -> EditConfigViewModel { + EditConfigViewModel { + entries: vec![], + is_editing_mode, + editing_tree_selector, + } } } + use super::*; + use helpers::*; #[test] fn keys_hint_shows_cycle_bindings_on_the_tree_row() { From d0c9c0dd790091571549a7e459c47f971167779f Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 14:52:08 -0300 Subject: [PATCH 56/61] test: share one named-field TempDir The filesystem, process, and kw readiness suites each owned a TempDir(PathBuf) tuple. They now share TempDir { path } in test_support, including the same Drop cleanup. Signed-off-by: lorenzoberts --- src/infrastructure/file_system/tests.rs | 31 +------------------- src/infrastructure/process/tests.rs | 29 ++----------------- src/kw/readiness.rs | 38 ++----------------------- src/main.rs | 3 ++ src/test_support.rs | 29 +++++++++++++++++++ 5 files changed, 38 insertions(+), 92 deletions(-) create mode 100644 src/test_support.rs diff --git a/src/infrastructure/file_system/tests.rs b/src/infrastructure/file_system/tests.rs index 43ed314..34f8eea 100644 --- a/src/infrastructure/file_system/tests.rs +++ b/src/infrastructure/file_system/tests.rs @@ -1,37 +1,8 @@ use std::path::Path; use super::{FileSystemTrait, OsFileSystem}; +use crate::test_support::TempDir; use std::fs; -mod helpers { - - use std::env; - use std::fs; - use std::path::{Path, PathBuf}; - use std::process; - - pub struct TempDir(PathBuf); - - impl TempDir { - pub(super) fn new(test_name: &str) -> Self { - let dir = - env::temp_dir().join(format!("patch_hub_fs_test_{}_{test_name}", process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - Self(dir) - } - - pub(super) fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } -} -pub use helpers::*; #[test] fn read_to_string_returns_file_contents() { diff --git a/src/infrastructure/process/tests.rs b/src/infrastructure/process/tests.rs index a6ba689..8dd867d 100644 --- a/src/infrastructure/process/tests.rs +++ b/src/infrastructure/process/tests.rs @@ -5,45 +5,20 @@ use super::{ RunningProcess, }; use crate::infrastructure::shell::ShellCommand; +use crate::test_support::TempDir; use std::fs; use tokio::time; mod helpers { use nix::sys::signal::Signal; use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; - use std::env; use std::fs; - use std::process; use std::{ - path::{Path, PathBuf}, + path::Path, time::{Duration, Instant}, }; use tokio::time; - pub struct TempDir(PathBuf); - - impl TempDir { - pub(super) fn new(test_name: &str) -> Self { - let dir = env::temp_dir().join(format!( - "patch_hub_process_test_{}_{test_name}", - process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - Self(dir) - } - - pub(super) fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - pub async fn wait_for(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool { let start = Instant::now(); while !cond() { diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index 5a07814..b8b5f9c 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -504,8 +504,7 @@ mod tests { mod helpers { use std::{ fs, - path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, + path::Path, time::{Duration, SystemTime}, }; @@ -514,39 +513,7 @@ mod tests { use crate::kw::models::history::KwBuildRecord; - use std::env; - - use std::process; - - pub(super) static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - - pub(super) struct TempDir(PathBuf); - - impl TempDir { - pub(super) fn new(test_name: &str) -> Self { - let n = TEST_SEQ.fetch_add(1, Ordering::SeqCst); - let dir = env::temp_dir().join(format!( - "patch-hub-kw-readiness-{}-{}-{}", - test_name, - process::id(), - n - )); - // A leftover from a failed previous run must not poison this one. - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).expect("dir creates"); - Self(dir) - } - - pub(super) fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } + use crate::test_support::TempDir; pub(super) const KERNEL_ROOT_FILES: [&str; 6] = [ "COPYING", @@ -631,6 +598,7 @@ mod tests { } } } + use crate::test_support::TempDir; use helpers::*; use std::{ fs, diff --git a/src/main.rs b/src/main.rs index b7920ad..c8956b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,9 @@ mod render_prefs; mod terminal; mod ui; +#[cfg(test)] +mod test_support; + use app::{actor::AppActor, dependencies::DependencyService, App}; use clap::Parser; use cli::Cli; diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 0000000..cd35324 --- /dev/null +++ b/src/test_support.rs @@ -0,0 +1,29 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static SEQ: AtomicU64 = AtomicU64::new(0); + +pub struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub fn new(label: &str) -> Self { + let n = SEQ.fetch_add(1, Ordering::SeqCst); + let dir = + std::env::temp_dir().join(format!("patch-hub-{label}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir creates"); + Self { path: dir } + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} From c68b1c0ae75c00399c4406b8debe206419438ae4 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 15:11:46 -0300 Subject: [PATCH 57/61] chore(lints): deny unwrap and allow attributes in clippy Deny unwrap_used and allow_attributes so new unwrap calls and #[allow] attributes fail clippy. Warn on redundant type annotations and single-variant match wildcards; CI denies warnings, so those are clean too. Test unwrap_err calls now use expect_err with a message for the expected failure. The patch-renderer match names Default, and four bindings drop type annotations rustc already infers. Signed-off-by: lorenzoberts --- Cargo.toml | 4 ++ src/app/actions/apply.rs | 18 +++--- src/app/dependencies.rs | 4 +- src/config/actor.rs | 4 +- src/config/tests.rs | 18 +++--- src/infrastructure/net/tests.rs | 2 +- src/kw/actor/actor_test.rs | 72 +++++++++++++++++----- src/kw/actor/deploy_test.rs | 47 +++++++++++--- src/kw/history.rs | 6 +- src/lore/domain/patch.rs | 6 +- src/lore/infrastructure/patchset_parser.rs | 2 +- 11 files changed, 129 insertions(+), 54 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 74e6fda..de147a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,10 @@ unconditional_recursion = "deny" [lints.clippy] map_unwrap_or = "deny" +unwrap_used = "deny" +allow_attributes = "deny" +redundant_type_annotations = "warn" +match_wildcard_for_single_variants = "warn" [workspace] members = [ diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index 86df17c..fc7621b 100644 --- a/src/app/actions/apply.rs +++ b/src/app/actions/apply.rs @@ -578,8 +578,8 @@ mod tests { let fs = clean_fs(); let (shell, calls) = shell_with_outputs(vec![output(" M file.rs\n", "", true)]); - let result = - ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); + let result = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()) + .expect_err("dirty worktree rejects"); assert!(result.contains("there are staged and/or unstaged changes")); assert_eq!(1, calls.lock().expect("calls locks").len()); @@ -595,7 +595,7 @@ mod tests { let result = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config_without_target()) - .unwrap_err(); + .expect_err("missing tree rejects"); assert_eq!("target kernel tree unset", result); } @@ -608,8 +608,8 @@ mod tests { output("", "missing branch", false), ]); - let result = - ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); + let result = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()) + .expect_err("invalid branch rejects"); assert!(result.contains("invalid branch 'main'")); assert_eq!(2, calls.lock().expect("calls locks").len()); @@ -631,8 +631,8 @@ mod tests { output("", "", true), ]); - let result = - ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config).unwrap_err(); + let result = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config) + .expect_err("git am fails"); assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed")); assert!(!result.contains("could not delete")); @@ -669,8 +669,8 @@ mod tests { output("", "error: branch is locked\n", false), ]); - let result = - ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()).unwrap_err(); + let result = ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config()) + .expect_err("git am fails"); assert!(result.starts_with(" `git am` failed (back on branch 'feature')\napply failed")); assert!(result.contains("could not delete 'patchset-")); diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index da2d581..b1200e5 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -60,7 +60,7 @@ impl DependencyService { ); } } - _ => {} + PatchRenderer::Default => {} } let kw = ReadinessService::probe_kw_binary(env, shell); @@ -145,7 +145,7 @@ mod tests { &shell, &ConfigSnapshot::from(&ConfigState::default()), ) - .unwrap_err(); + .expect_err("missing b4 errors"); assert!(matches!(err, AppError::Dependencies(_))); } diff --git a/src/config/actor.rs b/src/config/actor.rs index fcd5e3d..e79c1a7 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -238,7 +238,7 @@ mod tests { ..Default::default() }) .await - .unwrap_err(); + .expect_err("invalid update rejects"); assert!(matches!(err, ConfigError::InvalidPageSize(ref s) if s == "not-a-number")); assert_eq!( @@ -257,7 +257,7 @@ mod tests { let (handle, _home) = spawn_test_actor(); handle.shutdown().await; - let err = handle.get_snapshot().await.unwrap_err(); + let err = handle.get_snapshot().await.expect_err("actor has stopped"); assert!(matches!(err, ConfigError::ActorUnavailable(_))); } diff --git a/src/config/tests.rs b/src/config/tests.rs index bb0ab44..5eb0e43 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -564,7 +564,7 @@ fn validate_update_rejects_invalid_page_size() { &os_fs(), &ConfigState::default(), ) - .unwrap_err(); + .expect_err("invalid page size rejects"); assert!(matches!(err, ConfigError::InvalidPageSize(ref s) if s == "xyz")); } @@ -578,7 +578,7 @@ fn validate_update_rejects_invalid_patch_renderer() { &os_fs(), &ConfigState::default(), ) - .unwrap_err(); + .expect_err("invalid patch renderer rejects"); assert!(matches!( err, ConfigError::InvalidPatchRenderer(ref s) if s == "nope" @@ -595,7 +595,7 @@ fn validate_update_rejects_invalid_cover_renderer() { &os_fs(), &ConfigState::default(), ) - .unwrap_err(); + .expect_err("invalid cover renderer rejects"); assert!(matches!( err, ConfigError::InvalidCoverRenderer(ref s) if s == "delta" @@ -612,7 +612,7 @@ fn validate_update_rejects_invalid_max_log_age() { &os_fs(), &ConfigState::default(), ) - .unwrap_err(); + .expect_err("invalid max log age rejects"); assert!(matches!( err, ConfigError::InvalidMaxLogAge(ref s) if s == "not-a-number" @@ -630,7 +630,7 @@ fn validate_update_rejects_invalid_stay_on_applied_branch() { &os_fs(), &ConfigState::default(), ) - .unwrap_err(); + .expect_err("invalid stay-on flag rejects"); assert!(matches!( err, ConfigError::InvalidStayOnAppliedBranch(ref s) if s == raw @@ -672,7 +672,7 @@ fn validate_update_rejects_invalid_kw_deploy_bools() { &os_fs(), &ConfigState::default(), ) - .unwrap_err(); + .expect_err("invalid deploy flag rejects"); if expect_reboot_err { let raw = reboot.expect("reboot value is set"); assert!( @@ -754,7 +754,7 @@ fn validate_update_rejects_unknown_target_kernel_tree() { &os_fs(), &state, ) - .unwrap_err(); + .expect_err("unknown kernel tree rejects"); assert!(matches!( err, ConfigError::InvalidTargetKernelTree { ref key, .. } if key == "missing" @@ -800,7 +800,7 @@ fn validate_update_rejects_cache_dir_that_is_existing_file() { &os_fs(), &ConfigState::default(), ) - .unwrap_err(); + .expect_err("cache path rejects"); assert!(matches!(err, ConfigError::InvalidDirectory(_))); } @@ -916,7 +916,7 @@ async fn invalid_target_kernel_tree_keeps_existing_state() { ..Default::default() }) .await - .unwrap_err(); + .expect_err("invalid kernel tree rejects"); assert!(matches!( err, ConfigError::InvalidTargetKernelTree { ref key, .. } if key == "missing" diff --git a/src/infrastructure/net/tests.rs b/src/infrastructure/net/tests.rs index 4565a88..93227b9 100644 --- a/src/infrastructure/net/tests.rs +++ b/src/infrastructure/net/tests.rs @@ -28,7 +28,7 @@ fn mock_net_client_can_return_http_status_error() { let result = mock.request(HttpMethod::Get, "https://example.com/missing"); assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("request fails"); assert!(err.to_string().contains("404")); } diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index 4d65f0c..d3d9339 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -838,7 +838,10 @@ async fn record_apply_surfaces_store_errors() { MockEnvTrait::new(), ); - let err = handle.record_apply(apply_record()).await.unwrap_err(); + let err = handle + .record_apply(apply_record()) + .await + .expect_err("apply record fails"); assert!(matches!(err, KwError::History(_))); handle.shutdown().await; @@ -1216,7 +1219,10 @@ async fn spawn_failure_refuses_start_and_stays_idle() { let (handle, process, log_dir) = spawn_job_actor("spawn-fail"); process.refuse_spawns(true); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("spawn fails"); assert!(matches!(err, KwStartError::Spawn(_))); assert_eq!( @@ -1255,7 +1261,10 @@ async fn start_build_refused_when_kw_binary_missing() { log_dir.clone(), ); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("missing kw refuses build"); assert!(matches!(err, KwStartError::KwBinaryMissing)); assert_eq!( @@ -1333,7 +1342,10 @@ async fn start_build_refused_when_tree_not_ready() { log_dir.clone(), ); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("unreadiness refuses build"); assert!(matches!( err, @@ -1437,7 +1449,10 @@ async fn start_build_refused_when_worktree_is_dirty() { expect_ready_tree(&mut fs); let (handle, process, log_dir) = spawn_job_actor_with_mocks("dirty", shell, fs); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("dirty worktree refuses build"); assert!(matches!(err, KwStartError::DirtyWorktree)); assert_eq!( @@ -1467,7 +1482,10 @@ async fn start_build_refused_when_git_state_is_unverifiable() { expect_ready_tree(&mut fs); let (handle, process, log_dir) = spawn_job_actor_with_mocks("git-probe-fail", shell, fs); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("unverifiable git refuses build"); assert!(matches!(err, KwStartError::GitStateProbe(_))); assert!(err.to_string().contains("not a git repository")); @@ -1493,7 +1511,10 @@ async fn start_build_refused_when_branch_switch_fails() { ); let (handle, process, log_dir) = spawn_job_actor_with_mocks("switch-fail", shell, ready_fs()); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("branch switch refuses build"); assert!(matches!(err, KwStartError::CheckoutFailed(_))); assert!(err.to_string().contains("did not match")); @@ -1579,7 +1600,10 @@ async fn restore_switches_back_to_pre_job_branch_and_is_consumed() { // A successful restore consumes the context: a second restore has // nothing to do. - let err = handle.restore_previous_branch().await.unwrap_err(); + let err = handle + .restore_previous_branch() + .await + .expect_err("second restore has no branch"); assert!(matches!(err, KwError::NoRecordedBranch)); handle.shutdown().await; @@ -1596,7 +1620,10 @@ async fn restore_refused_while_job_is_running() { .start_build(start_request()) .await .expect("build starts"); - let err = handle.restore_previous_branch().await.unwrap_err(); + let err = handle + .restore_previous_branch() + .await + .expect_err("restore refused while running"); assert!(matches!(err, KwError::JobRunning)); // The context survives the refusal: restore works once the job @@ -1629,7 +1656,10 @@ async fn restore_refused_when_worktree_is_dirty() { let _ = wait_for_terminal_status(&mut watch).await; git.set_dirty(true); - let err = handle.restore_previous_branch().await.unwrap_err(); + let err = handle + .restore_previous_branch() + .await + .expect_err("dirty worktree refuses restore"); assert!(matches!(err, KwError::DirtyWorktree)); // The refused restore did not touch the tree. assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); @@ -1670,7 +1700,10 @@ async fn restore_failure_keeps_the_context_for_a_retry() { let _ = wait_for_terminal_status(&mut watch).await; git.fail_switches_to(Some("master")); - let err = handle.restore_previous_branch().await.unwrap_err(); + let err = handle + .restore_previous_branch() + .await + .expect_err("restore fails"); assert!(matches!(err, KwError::CheckoutFailed(_))); assert!(err.to_string().contains("resolve your current index")); @@ -1706,7 +1739,10 @@ async fn refused_start_does_not_clobber_the_restore_context() { process.refuse_spawns(true); let mut second = start_request(); second.branch = "patchset-two".to_string(); - let err = handle.start_build(second).await.unwrap_err(); + let err = handle + .start_build(second) + .await + .expect_err("refused start stays refused"); assert!(matches!(err, KwStartError::Spawn(_))); assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); @@ -1731,7 +1767,7 @@ async fn shutdown_stops_actor() { ); handle.shutdown().await; - let err = handle.get_status().await.unwrap_err(); + let err = handle.get_status().await.expect_err("actor has stopped"); assert!(matches!(err, KwError::ActorUnavailable(_))); } @@ -1751,7 +1787,10 @@ async fn log_dir_creation_failure_refuses_start_and_stays_idle() { }); let (handle, process, log_dir) = spawn_job_actor_with_mocks("log-dir-fail", git.shell(), fs); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("log dir failure refuses start"); assert!(matches!(err, KwStartError::Fs(_))); assert_eq!( @@ -1780,7 +1819,10 @@ async fn rollback_failure_keeps_the_spawn_refusal() { spawn_job_actor_with_mocks("rollback-fails", git.shell(), ready_fs()); process.refuse_spawns(true); - let err = handle.start_build(start_request()).await.unwrap_err(); + let err = handle + .start_build(start_request()) + .await + .expect_err("spawn stays refused"); assert!(matches!(err, KwStartError::Spawn(_))); // The rollback failure is logged, not reported: the caller keeps diff --git a/src/kw/actor/deploy_test.rs b/src/kw/actor/deploy_test.rs index aef40b0..2dd699a 100644 --- a/src/kw/actor/deploy_test.rs +++ b/src/kw/actor/deploy_test.rs @@ -197,7 +197,10 @@ async fn start_deploy_refused_without_a_build_record() { let (handle, process, log_dir) = spawn_deploy_actor("deploy-no-record", deploy_history(None), deploy_ready_fs()); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("deploy refused without a build"); assert!(matches!( err, @@ -223,7 +226,10 @@ async fn start_deploy_refused_when_last_build_failed() { deploy_ready_fs(), ); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("failed build refuses deploy"); assert!(matches!( err, @@ -245,7 +251,10 @@ async fn start_deploy_refused_on_tree_path_drift() { deploy_ready_fs(), ); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("tree drift refuses deploy"); assert!(matches!( err, @@ -267,7 +276,10 @@ async fn start_deploy_refused_on_output_dir_mismatch() { deploy_ready_fs(), ); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("output dir mismatch refuses deploy"); assert!(matches!( err, @@ -287,7 +299,10 @@ async fn start_deploy_refused_when_image_is_missing() { deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, false), ); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("missing image refuses deploy"); assert!(matches!( err, @@ -307,7 +322,10 @@ async fn start_deploy_refused_when_remote_is_unresolved() { deploy_fs("", DEPLOY_BOOT_ONCE_OFF, true), ); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("unresolved remote refuses deploy"); assert!(matches!( err, @@ -327,7 +345,10 @@ async fn start_deploy_refused_when_boot_once_is_on_and_unacked() { deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_ON, true), ); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("unacked boot-once refuses deploy"); assert!(matches!(err, KwStartError::BootOnceNotAcknowledged)); assert!(process.spawned().is_empty()); @@ -350,7 +371,10 @@ async fn start_deploy_refused_when_latest_build_is_on_another_branch() { let (handle, process, log_dir) = spawn_deploy_actor("deploy-head-mismatch", history, deploy_ready_fs()); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("other branch refuses deploy"); assert!(matches!( err, @@ -391,7 +415,10 @@ async fn start_deploy_post_switch_refusal_rolls_the_switch_back() { env_with_kw(), ); - let err = handle.start_deploy(deploy_request()).await.unwrap_err(); + let err = handle + .start_deploy(deploy_request()) + .await + .expect_err("post-switch refusal rolls back"); assert!(matches!( err, @@ -690,7 +717,7 @@ async fn start_build_then_deploy_refused_when_remote_is_unresolved() { let err = handle .start_build_then_deploy(deploy_request()) .await - .unwrap_err(); + .expect_err("unresolved remote refuses chain"); assert!(matches!( err, diff --git a/src/kw/history.rs b/src/kw/history.rs index 1061670..3927433 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -516,7 +516,7 @@ mod tests { let err = store .apply_record_for_branch("mainline", "patchset-x") - .unwrap_err(); + .expect_err("corrupt history errors"); // Errors name the file so the warning popup can point at it. assert!(err.to_string().contains(APPLY_HISTORY_FILENAME)); assert!(store @@ -762,7 +762,9 @@ mod tests { let store = store_at(&dir); fs::write(dir.join(BUILD_HISTORY_FILENAME), b"not json").expect("file writes"); - let err = store.build_records("mainline", "for-next").unwrap_err(); + let err = store + .build_records("mainline", "for-next") + .expect_err("corrupt build history errors"); assert!(err.to_string().contains(BUILD_HISTORY_FILENAME)); assert!(store .record_build(build("mainline", "for-next", "2026-08-01T18:10:00Z")) diff --git a/src/lore/domain/patch.rs b/src/lore/domain/patch.rs index 9fd8b89..1a8aa67 100644 --- a/src/lore/domain/patch.rs +++ b/src/lore/domain/patch.rs @@ -180,7 +180,7 @@ mod tests { updated, } }; - let serialized_patch: &str = r#" + let serialized_patch = r#" Foo Bar @@ -230,7 +230,7 @@ mod tests { updated, } }; - let serialized_patch: &str = r#" + let serialized_patch = r#" Foo Bar @@ -258,7 +258,7 @@ mod tests { #[test] fn test_update_patch_metadata() { - let patch_regex: PatchRegex = PatchRegex::new(); + let patch_regex = PatchRegex::new(); let mut patch: Patch = { let title = "[RESEND][v7 PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything" diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index 144be1f..6db14ba 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -113,7 +113,7 @@ impl PatchsetTextService { /// cover with an empty diff slice. pub fn split_cover(patch: &str) -> (&str, &str) { let mut cover: &str = patch; - let mut diff: &str = ""; + let mut diff = ""; if let Some(cover_end) = patch.find("\n---\n") { cover = &patch[..cover_end + 1]; From e5cedb27085dc90a710ecf95061bed2b4575e229 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 15:39:29 -0300 Subject: [PATCH 58/61] style: merge split use statements into existing groups Paths that were hoisted onto their own use lines now sit in the std, external, or crate group already present in the file. Signed-off-by: lorenzoberts --- src/app/actor.rs | 10 ++++--- src/app/flows/edit_config.rs | 6 ++-- src/app/integration_tests/kw_status.rs | 28 ++++++++----------- src/app/integration_tests/patchset_actions.rs | 17 +++++------ src/app/view_model.rs | 10 +++---- src/config/actor.rs | 5 ++-- src/config/tests.rs | 5 ++-- src/infrastructure/file_system/tests.rs | 3 +- src/infrastructure/process/tests.rs | 16 +++++++---- src/kw/actor/actor_test.rs | 16 +++++------ src/kw/history.rs | 19 ++++++------- src/kw/readiness.rs | 8 ++---- src/kw/remote.rs | 8 ++---- src/lore/infrastructure/patchset_fetcher.rs | 3 +- src/lore/infrastructure/patchset_parser.rs | 4 +-- src/lore/infrastructure/persistence.rs | 15 ++++------ 16 files changed, 75 insertions(+), 98 deletions(-) diff --git a/src/app/actor.rs b/src/app/actor.rs index 59aaca0..0063cf8 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -3,10 +3,14 @@ //! Each frame projects `AppViewModel`, draws through `TerminalHandle`, then //! awaits an `InputEvent`, a kw-status change, or a KwOps log tick. It stops //! when the input channel closes or I/O returns an unrecoverable error. -use std::{ops::ControlFlow, time::Duration}; +use std::{future::pending, ops::ControlFlow, time::Duration}; use color_eyre::{eyre::eyre, Report, Result}; -use tokio::{spawn, sync::mpsc, sync::watch, time::MissedTickBehavior}; +use tokio::{ + spawn, + sync::{mpsc, watch}, + time::{self, MissedTickBehavior}, +}; use crate::{ app::{ @@ -24,8 +28,6 @@ use crate::{ terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, ui::handle::UiHandle, }; -use std::future::pending; -use tokio::time; /// Owns `App` state and drives the main loop on a dedicated task. /// diff --git a/src/app/flows/edit_config.rs b/src/app/flows/edit_config.rs index 3e03de2..717b48d 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -117,9 +117,10 @@ mod tests { }; use std::{ collections::HashMap, - env::VarError, + env::{self, VarError}, fs, path::PathBuf, + process, sync::{ atomic::{AtomicU64, Ordering}, Arc, @@ -127,9 +128,6 @@ mod tests { }; use tokio::sync::mpsc; - use std::env; - use std::process; - pub(super) static TEST_SEQ: AtomicU64 = AtomicU64::new(0); pub(super) fn unique_test_dir(prefix: &str) -> PathBuf { diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index 2776ca5..c4334a9 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -1,21 +1,18 @@ -use std::time::Duration; +use std::{fs, time::Duration}; + +use tokio::time; use crate::input::event::InputEvent; use crate::ui::scene::PopupBody; -use std::fs; -use tokio::time; mod helpers { use super::super::helpers::{ app_harness::{dummy_config_handle, dummy_render_handle}, lore::{lore_handle_with_persistence, sample_mailing_list}, }; - use crate::app::handle::AppHandle; - use crate::app::App; use crate::kw::handle::KwHandle; - use crate::ui::scene::PopupScene; use crate::{ - app::actor::AppActor, + app::{actor::AppActor, handle::AppHandle, App}, config::{ConfigSnapshot, ConfigState}, infrastructure::{ env::MockEnvTrait, @@ -29,23 +26,22 @@ mod helpers { terminal::{ actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, }, - ui::{actor::UiActor, scene::UiScene}, + ui::{ + actor::UiActor, + scene::{PopupScene, UiScene}, + }, }; - use std::env; - use std::fs; - use std::io; - use std::path::Path; - use std::process; use std::{ - path::PathBuf, + env, fs, io, + path::{Path, PathBuf}, + process, sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, }, time::Duration, }; - use tokio::sync::mpsc; - use tokio::time; + use tokio::{sync::mpsc, time}; pub const KERNEL_TREE_PATH: &str = "/kernel"; pub const BUILD_BRANCH: &str = "patchset-2026-08-20-15-00-00"; diff --git a/src/app/integration_tests/patchset_actions.rs b/src/app/integration_tests/patchset_actions.rs index c6a45b3..7382a27 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -1,18 +1,18 @@ use std::{ collections::HashSet, + fs, io, sync::{Arc, Mutex}, + time::Duration, }; +use tokio::time; + +use super::helpers::lore::lore_handle_with_persistence; use crate::{ infrastructure::{file_system::FileSystemError, process::FakeProcess, shell::MockShellTrait}, kw::{history::MockKwHistoryStore, status::KwJobStatus}, }; -use super::helpers::lore::lore_handle_with_persistence; -use std::fs; -use std::io; -use std::time::Duration; -use tokio::time; mod helpers { use super::super::helpers::{ app_harness::{dummy_config_handle, dummy_render_handle}, @@ -43,14 +43,11 @@ mod helpers { handle::LoreApiHandle, messages::LoreApiMessage, models::cache::BootstrapLoreData, }, }; - use std::env; - use std::fs; - use std::io; - use std::process; - use std::{ collections::{HashMap, HashSet, VecDeque}, + env, fs, io, path::PathBuf, + process, sync::{Arc, Mutex}, }; diff --git a/src/app/view_model.rs b/src/app/view_model.rs index 44c3e57..7ca7861 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -667,12 +667,9 @@ mod tests { mod helpers { use super::super::*; - use crate::{ - app::models::kw_ops::KwOpsState, - kw::models::readiness::{KwReadiness, KwVersionCheck}, - }; use crate::{ app::{ + models::kw_ops::KwOpsState, screens::{ bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, }, @@ -681,7 +678,10 @@ mod tests { }, }, config::{ConfigSnapshot, ConfigState}, - kw::status::{KwJobKind, KwJobStatus, KwStatusSnapshot}, + kw::{ + models::readiness::{KwReadiness, KwVersionCheck}, + status::{KwJobKind, KwJobStatus, KwStatusSnapshot}, + }, lore::domain::mailing_list::MailingList, }; use std::{collections::HashMap, path::PathBuf}; diff --git a/src/config/actor.rs b/src/config/actor.rs index e79c1a7..56d2438 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -117,12 +117,11 @@ mod tests { config::service::ConfigService, infrastructure::{env::MockEnvTrait, file_system::OsFileSystem}, }; - use std::env; - use std::process; use std::{ - env::VarError, + env::{self, VarError}, fs, path::PathBuf, + process, sync::atomic::{AtomicU64, Ordering}, }; diff --git a/src/config/tests.rs b/src/config/tests.rs index 5eb0e43..8339f6b 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -19,12 +19,11 @@ mod helpers { file_system::OsFileSystem, }; use serde_json::json; - use std::env; - use std::process; use std::{ - env::VarError, + env::{self, VarError}, fs, path::{Path, PathBuf}, + process, sync::atomic::{AtomicU64, Ordering}, }; diff --git a/src/infrastructure/file_system/tests.rs b/src/infrastructure/file_system/tests.rs index 34f8eea..46eae9a 100644 --- a/src/infrastructure/file_system/tests.rs +++ b/src/infrastructure/file_system/tests.rs @@ -1,8 +1,7 @@ -use std::path::Path; +use std::{fs, path::Path}; use super::{FileSystemTrait, OsFileSystem}; use crate::test_support::TempDir; -use std::fs; #[test] fn read_to_string_returns_file_contents() { diff --git a/src/infrastructure/process/tests.rs b/src/infrastructure/process/tests.rs index 8dd867d..94d711b 100644 --- a/src/infrastructure/process/tests.rs +++ b/src/infrastructure/process/tests.rs @@ -1,4 +1,6 @@ -use std::{io, os::unix::process::ExitStatusExt, process::ExitStatus, time::Duration}; +use std::{fs, io, os::unix::process::ExitStatusExt, process::ExitStatus, time::Duration}; + +use tokio::time; use super::{ FakeProcess, MockProcessTrait, MockRunningProcess, OsProcess, ProcessError, ProcessTrait, @@ -6,14 +8,16 @@ use super::{ }; use crate::infrastructure::shell::ShellCommand; use crate::test_support::TempDir; -use std::fs; -use tokio::time; + mod helpers { - use nix::sys::signal::Signal; - use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; - use std::fs; + use nix::{ + errno::Errno, + sys::signal::{kill, Signal}, + unistd::Pid, + }; use std::{ + fs, path::Path, time::{Duration, Instant}, }; diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs index d3d9339..3a0816b 100644 --- a/src/kw/actor/actor_test.rs +++ b/src/kw/actor/actor_test.rs @@ -1,5 +1,5 @@ use std::{ - io, + env, fs, io, path::Path, sync::{ atomic::{AtomicU64, Ordering}, @@ -8,6 +8,9 @@ use std::{ time::Duration, }; +use chrono::DateTime; +use tokio::time; + use crate::{ infrastructure::{ env::MockEnvTrait, @@ -27,10 +30,7 @@ use crate::{ }; use super::*; -use chrono::DateTime; -use std::env; -use std::fs; -use tokio::time; + mod helpers { use super::super::*; use crate::{ @@ -48,12 +48,10 @@ mod helpers { }, }; - use std::env; - use std::fs; - use std::process; use std::{ - io, + env, fs, io, path::Path, + process, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Mutex, diff --git a/src/kw/history.rs b/src/kw/history.rs index 3927433..c0849ab 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -4,17 +4,15 @@ //! Apply and build records are user state — not a cache — and are never //! refreshed from lore. +use chrono::DateTime; use mockall::automock; +use serde::de::DeserializeOwned; use serde_json::from_reader; use std::{collections::HashMap, io, path::Path, sync::Arc}; use crate::infrastructure::file_system::{FileSystemError, FileSystemTrait, JsonUtils}; - -use crate::kw::models::history::KwApplyRecord; -use crate::kw::models::history::KwBuildRecord; -use chrono::DateTime; -use serde::de::DeserializeOwned; +use crate::kw::models::history::{KwApplyRecord, KwBuildRecord}; pub const APPLY_HISTORY_FILENAME: &str = "kw_apply_history.json"; pub const BUILD_HISTORY_FILENAME: &str = "kw_build_history.json"; @@ -177,11 +175,12 @@ mod tests { mod helpers { use super::super::*; use crate::infrastructure::file_system::OsFileSystem; - use std::env; - use std::fs; - use std::path::PathBuf; - use std::process; - use std::sync::atomic::{AtomicU64, Ordering}; + use std::{ + env, fs, + path::PathBuf, + process, + sync::atomic::{AtomicU64, Ordering}, + }; pub(super) static TEST_SEQ: AtomicU64 = AtomicU64::new(0); diff --git a/src/kw/readiness.rs b/src/kw/readiness.rs index b8b5f9c..48dcf65 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -601,8 +601,9 @@ mod tests { use crate::test_support::TempDir; use helpers::*; use std::{ - fs, + env, fs, io, path::{Path, PathBuf}, + sync::Arc, }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; @@ -618,11 +619,6 @@ mod tests { use super::*; - use std::env; - use std::io; - - use std::sync::Arc; - #[test] fn parse_kw_config_mirrors_kw_semantics() { // Deliberately no trailing newline: kw's read loop handles a final diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 59d997e..4c63e6d 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -213,9 +213,7 @@ mod tests { file_system::{FileSystemError, MockFileSystemTrait}, }; use crate::kw::models::remote::RemoteRefusal; - use std::env; - use std::io; - use std::{collections::HashMap, path::PathBuf, sync::Arc}; + use std::{collections::HashMap, env, io, path::PathBuf, sync::Arc}; /// Example remote.config: two hosts, default on the second, fields indented. pub(super) const SAMPLE_REMOTE_CONFIG: &str = "\ @@ -314,15 +312,13 @@ mod tests { } } use helpers::*; - use std::path::Path; + use std::{io, path::Path}; use crate::infrastructure::file_system::{FileSystemError, MockFileSystemTrait}; use super::*; use crate::kw::models::remote::RemoteRefusal; - use std::io; - #[test] fn plan_fixture_picks_the_default_host() { let parsed = RemoteConfigService::parse_remote_config(SAMPLE_REMOTE_CONFIG); diff --git a/src/lore/infrastructure/patchset_fetcher.rs b/src/lore/infrastructure/patchset_fetcher.rs index 237a836..9fe112e 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -143,8 +143,7 @@ mod tests { }; use helpers::*; - use std::io; - use std::sync::atomic; + use std::{io, sync::atomic}; #[test] fn download_skips_b4_when_file_already_exists() { diff --git a/src/lore/infrastructure/patchset_parser.rs b/src/lore/infrastructure/patchset_parser.rs index 6db14ba..8de6449 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -214,11 +214,9 @@ mod tests { } } use helpers::*; - use std::fs; + use std::{env, fs, process}; use super::*; - use std::env; - use std::process; #[test] fn split_patchset_returns_error_on_missing_path() { diff --git a/src/lore/infrastructure/persistence.rs b/src/lore/infrastructure/persistence.rs index c6b10b9..15caa7d 100644 --- a/src/lore/infrastructure/persistence.rs +++ b/src/lore/infrastructure/persistence.rs @@ -106,14 +106,10 @@ mod tests { mod helpers { - use crate::infrastructure::file_system::OsFileSystem; - use std::env; - use std::fs; - use std::sync::Arc; + use std::{env, fs, path::PathBuf, process, sync::Arc}; use super::super::*; - use std::path::PathBuf; - use std::process; + use crate::infrastructure::file_system::OsFileSystem; pub(super) fn tmp_dir(test_name: &str) -> PathBuf { let dir = env::temp_dir().join(format!( @@ -144,9 +140,10 @@ mod tests { } } use helpers::*; - use std::collections::{HashMap, HashSet}; - - use std::fs; + use std::{ + collections::{HashMap, HashSet}, + fs, + }; use crate::lore::domain::mailing_list::MailingList; From 096c06a07313f0e89275a0eea41ebad7806a65df Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 15:39:29 -0300 Subject: [PATCH 59/61] refactor: hold the kw actor handle directly in main KwActor::spawn always runs in this unix-only binary, so main keeps the handle itself. Shutdown is still after the input actor and before the config actor. Signed-off-by: lorenzoberts --- src/main.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index c8956b0..b0579e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,14 +110,14 @@ async fn main() -> Result<()> { config.data_dir().to_string(), )); - let kw_handle = Some(KwActor::spawn( + let kw_handle = KwActor::spawn( kw_history.clone(), Arc::new(OsProcess), shell_arc.clone(), fs_arc.clone(), Arc::new(OsEnv), Path::new(config.cache_dir()).join("kw_logs"), - )); + ); let render = RenderActor::spawn(Box::new(ShellRenderService::new(shell_arc.clone()))); @@ -150,7 +150,7 @@ async fn main() -> Result<()> { lore_api.clone(), render.clone(), kw_history.clone(), - kw_handle.clone(), + Some(kw_handle.clone()), )?; let (app_input_tx, app_input_rx) = mpsc::channel::(64); let input_handle = InputActor::spawn(terminal_handle.clone(), app.input_context()); @@ -177,9 +177,7 @@ async fn main() -> Result<()> { .shutdown() .await .map_err(|e| eyre!("{e}"))?; - if let Some(kw_handle) = kw_handle { - kw_handle.shutdown().await; - } + kw_handle.shutdown().await; config_handle.shutdown().await; lore_api.shutdown().await; render.shutdown().await; From b790aea1152e73087524aaedd3356464d755b274 Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 15:42:59 -0300 Subject: [PATCH 60/61] test(app): unwrap kw_ops integration tests from the unix module The non-test helpers move into the helpers submodule next to the other kw_ops fixtures. Test paths drop the unix segment. Signed-off-by: lorenzoberts --- src/app/integration_tests/kw_ops.rs | 1496 ++++++++++++++------------- 1 file changed, 749 insertions(+), 747 deletions(-) diff --git a/src/app/integration_tests/kw_ops.rs b/src/app/integration_tests/kw_ops.rs index 25284e0..3a8344b 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -1,6 +1,25 @@ -use crate::{app::screens::CurrentScreen, input::event::InputEvent}; - -use super::helpers::app_harness::{dummy_terminal_handle, AppHarness}; +use std::{fs, sync::Arc, time::Duration}; + +use tokio::time; + +use super::helpers::app_harness::AppHarness; +use crate::app::{ + integration_tests::helpers::app_harness::dummy_terminal_handle, + models::{kw_ops::DeployStartKind, popup::AppPopup}, + screens::CurrentScreen, +}; +use crate::{ + infrastructure::{ + file_system::{MockFileSystemTrait, OsFileSystem}, + process::FakeProcess, + }, + input::event::InputEvent, + kw::{ + history::MockKwHistoryStore, + models::readiness::{BootOnceState, DeployAloneRefusal}, + status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, + }, +}; #[tokio::test] async fn open_kw_ops_without_actor_stays_on_details() { @@ -29,35 +48,7 @@ mod helpers { lore::{sample_patch, sample_patchset_details}, render::sample_rendered_preview, }; - use crate::app::{ - models::popup::AppPopup, - screens::{details_actions::PatchsetDetailsState, CurrentScreen}, - }; - - pub fn details_state() -> PatchsetDetailsState { - PatchsetDetailsState::build_from_rendered_preview( - sample_patch(), - sample_patchset_details(), - sample_rendered_preview(), - false, - CurrentScreen::LatestPatchsets, - ) - } - - pub fn assert_info_popup(popup: Option<&AppPopup>, expected_title: &str, fragment: &str) { - let Some(AppPopup::Info { title, body, .. }) = popup else { - panic!("expected info popup"); - }; - assert_eq!(expected_title, title); - assert!( - body.contains(fragment), - "expected popup body to contain {fragment:?}, got {body:?}" - ); - } -} -pub use helpers::*; - -mod unix { + use crate::app::screens::details_actions::PatchsetDetailsState; use std::{ env, fs, io, path::{Path, PathBuf}, @@ -77,20 +68,12 @@ mod unix { config::{ConfigSnapshot, ConfigState}, infrastructure::{ env::MockEnvTrait, - file_system::{FileSystemError, FileSystemTrait, MockFileSystemTrait, OsFileSystem}, + file_system::{FileSystemError, FileSystemTrait, MockFileSystemTrait}, process::FakeProcess, shell::{MockShellTrait, ShellOutput}, }, input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, - kw::{ - actor::KwActor, - history::MockKwHistoryStore, - models::{ - history::KwBuildRecord, - readiness::{BootOnceState, DeployAloneRefusal}, - }, - status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, - }, + kw::{actor::KwActor, history::MockKwHistoryStore, models::history::KwBuildRecord}, lore::application::models::cache::BootstrapLoreData, terminal::{ actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, @@ -101,703 +84,36 @@ mod unix { }, }; - use super::{assert_info_popup, details_state}; use crate::app::{ integration_tests::helpers::{ - app_harness::{dummy_config_handle, dummy_render_handle, dummy_terminal_handle}, + app_harness::{dummy_config_handle, dummy_render_handle}, lore::{lore_handle_with_persistence, sample_mailing_list}, }, - models::{kw_ops::DeployStartKind, popup::AppPopup}, + models::popup::AppPopup, }; static LOG_DIR_SEQ: AtomicU64 = AtomicU64::new(0); - - #[tokio::test] - async fn open_kw_ops_prefills_branch_from_head_not_tree_config() { - let log_dir = kw_log_dir("open-head"); - let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); - - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); - let ops = app.state.kw.ops.as_ref().expect("KwOps state"); - assert_eq!("feature", ops.branch); - assert_eq!("main", ops.tree.branch()); - assert!(!ops.head_unreadable); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn start_rejects_empty_branch() { - let log_dir = kw_log_dir("empty-branch"); - let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("")); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - assert!(app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.branch.is_empty())); - - app.handle_kw_ops(InputEvent::StartKwBuild) - .await - .expect("kw ops handles"); - assert_info_popup( - app.state.popup.as_ref(), - "Cannot start build", - "detached or unverifiable", - ); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn start_build_accepts_and_cancel_sets_requested() { - let log_dir = kw_log_dir("start-cancel"); - let process = Arc::new(FakeProcess::new()); - let mut app = app_with_details_and_kw_process( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - app.handle_kw_ops(InputEvent::StartKwBuild) - .await - .expect("kw ops handles"); - assert!(app.state.popup.is_none()); - let spawned = process.spawned(); - assert_eq!(1, spawned.len()); - assert_eq!(vec!["build"], spawned[0].args); - - let kw = app.services.kw.as_ref().expect("kw is set").clone(); - let status = kw.get_status().await.expect("status loads"); - app.state.kw.status = Some(status); - assert!(matches!( - app.state.kw.status.as_ref().map(|s| &s.job), - Some(KwJobStatus::Running { .. }) - )); - - app.handle_kw_ops(InputEvent::CancelKwJob) - .await - .expect("kw ops handles"); - assert!(app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.cancel_requested)); - - process.last_child().finish(0); - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn second_start_while_busy_is_absorbed() { - let log_dir = kw_log_dir("double-start"); - let process = Arc::new(FakeProcess::new()); - let mut app = app_with_details_and_kw_process( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - app.handle_kw_ops(InputEvent::StartKwBuild) - .await - .expect("kw ops handles"); - assert!(app.state.popup.is_none()); - assert!(matches!( - app.state.kw.status.as_ref().map(|s| &s.job), - Some(KwJobStatus::Running { .. }) - )); - assert!(!app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.start_requested)); - - app.handle_kw_ops(InputEvent::StartKwBuild) - .await - .expect("kw ops handles"); - assert!(app.state.popup.is_none()); - assert_eq!(1, process.spawned().len()); - - process.last_child().finish(0); - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn fallback_seeds_a_running_job_when_projection_is_empty() { - let log_dir = kw_log_dir("fallback-seed"); - let process = Arc::new(FakeProcess::new()); - let mut app = app_with_details_and_kw_process( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - app.handle_kw_ops(InputEvent::StartKwBuild) - .await - .expect("kw ops handles"); - app.state.kw.status = None; - - app.fallback_kw_status().await; - assert!(matches!( - app.state.kw.status.as_ref().map(|s| &s.job), - Some(KwJobStatus::Running { .. }) - )); - - process.last_child().finish(0); - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn refresh_log_tail_goes_through_injected_fs() { - let log_dir = kw_log_dir("injected-fs"); - let mut fs = MockFileSystemTrait::new(); - fs.expect_read_tail_to_string() - .withf(|path, _max_bytes| path.extension() == Some("log".as_ref())) - .times(1) - .returning(|_, _| Ok("cc1: compiling from mock\n".to_string())); - let mut app = app_with_details_and_kw_process( - &log_dir, - head_branch_shell("feature"), - Arc::new(FakeProcess::new()), - Arc::new(fs), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - app.state.kw.status = Some(KwStatusSnapshot { - job: KwJobStatus::Running { - kind: KwJobKind::Build, - phase: KwPhase::Building, - kernel_tree_id: "linux".to_string(), - branch: "feature".to_string(), - log_path: log_dir.join("build.log"), - }, - restore_branch: None, - }); - - app.refresh_kw_ops_log_tail().await; - assert!(app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.log_tail.contains("from mock"))); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn reenter_keeps_typed_extras() { - let log_dir = kw_log_dir("reenter-extras"); - let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - app.state.kw.ops.as_mut().expect("ops is set").extra_args = "--verbose".to_string(); - - app.handle_kw_ops(InputEvent::Back) - .await - .expect("kw ops handles"); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - assert_eq!( - "--verbose", - app.state.kw.ops.as_ref().expect("ops is set").extra_args - ); - assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn back_returns_to_details() { - let log_dir = kw_log_dir("back"); - let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - app.handle_kw_ops(InputEvent::Back) - .await - .expect("kw ops handles"); - assert_eq!( - CurrentScreen::PatchsetDetails, - app.state.navigation.current_screen - ); - assert!(app.state.lore.details.is_some()); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn open_kw_ops_projects_the_resolved_remote() { - let log_dir = kw_log_dir("remote-display"); - let mut app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - Arc::new(FakeProcess::new()), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(true), - default_kw_history(), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - let ops = app.state.kw.ops.as_ref().expect("KwOps state"); - assert_eq!( - "root@box:22", - ops.readiness - .deploy_remote - .as_ref() - .expect("deploy remote is set") - .endpoint() - ); - assert_eq!(BootOnceState::Off, ops.readiness.boot_once); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); + pub fn details_state() -> PatchsetDetailsState { + PatchsetDetailsState::build_from_rendered_preview( + sample_patch(), + sample_patchset_details(), + sample_rendered_preview(), + false, + CurrentScreen::LatestPatchsets, + ) } - #[tokio::test] - async fn start_deploy_opens_the_boot_once_gate() { - let log_dir = kw_log_dir("deploy-gate"); - let process = Arc::new(FakeProcess::new()); - let mut app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(false), - feature_build_history(Some(matching_feature_build_record())), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - app.handle_kw_ops(InputEvent::StartKwDeploy) - .await - .expect("kw ops handles"); - - let Some(AppPopup::Confirm { title, .. }) = app.state.popup.as_ref() else { - panic!("expected boot-once confirm popup"); + pub fn assert_info_popup(popup: Option<&AppPopup>, expected_title: &str, fragment: &str) { + let Some(AppPopup::Info { title, body, .. }) = popup else { + panic!("expected info popup"); }; - assert_eq!("Boot into new kernel once?", title); - assert_eq!( - Some(DeployStartKind::Deploy), - app.state - .kw - .ops - .as_ref() - .expect("ops is set") - .pending_deploy - ); - assert!(process.spawned().is_empty()); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn start_deploy_refused_without_a_build_record() { - let log_dir = kw_log_dir("deploy-no-record"); - let process = Arc::new(FakeProcess::new()); - let mut app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(true), - default_kw_history(), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - app.handle_kw_ops(InputEvent::StartKwDeploy) - .await - .expect("kw ops handles"); - assert_info_popup( - app.state.popup.as_ref(), - "Cannot start deploy", - "no build recorded", - ); - assert!(process.spawned().is_empty()); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn start_deploy_accepts_when_ready() { - let log_dir = kw_log_dir("deploy-ready"); - let process = Arc::new(FakeProcess::new()); - let mut app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(true), - feature_build_history(Some(matching_feature_build_record())), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - app.handle_kw_ops(InputEvent::StartKwDeploy) - .await - .expect("kw ops handles"); - assert!(app.state.popup.is_none()); - let spawned = process.spawned(); - assert_eq!(1, spawned.len()); - assert_eq!( - [ - "deploy", - "--remote", - "root@box:22", - "--no-reboot", - "--force" - ] - .as_slice(), - spawned[0].args.as_slice() - ); - assert!(matches!( - app.state.kw.status.as_ref().map(|s| &s.job), - Some(KwJobStatus::Running { - kind: KwJobKind::Deploy, - phase: KwPhase::Deploying, - .. - }) - )); - - process.last_child().finish(0); - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn start_build_then_deploy_chains_two_processes() { - let log_dir = kw_log_dir("build-then-deploy"); - let process = Arc::new(FakeProcess::new()); - let mut app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(true), - default_kw_history(), - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - - app.handle_kw_ops(InputEvent::StartKwBuildThenDeploy) - .await - .expect("kw ops handles"); - assert!(app.state.popup.is_none()); - assert_eq!(vec!["build"], process.spawned()[0].args); - - process.last_child().finish(0); - time::timeout(Duration::from_secs(5), async { - loop { - if process.spawned().len() >= 2 { - return; - } - time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("deploy should spawn after a successful build"); - - let spawned = process.spawned(); - assert_eq!("deploy", spawned[1].args[0]); - assert!(spawned[1].args.iter().any(|arg| arg == "root@box:22")); - - process.last_child().finish(0); - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test] - async fn committing_a_branch_reprobes_deploy_alone() { - let log_dir = kw_log_dir("branch-reprobe"); - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .withf(|_, _| true) - .times(0) - .returning(|_, _| Ok(None)); - history - .expect_record_build() - .withf(|_| true) - .times(0) - .returning(|_| Ok(())); - history - .expect_build_records() - .withf(|tree, branch| tree == "linux" && (branch == "feature" || branch == "built")) - .times(2) - .returning(|_, branch| { - if branch == "built" { - Ok(( - Some(matching_build_record("built")), - Some(matching_build_record("built")), - )) - } else { - Ok((None, None)) - } - }); - let mut app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - Arc::new(FakeProcess::new()), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(true), - history, - ); - app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .expect("patchset details handles"); - assert!(matches!( - app.state - .kw - .ops - .as_ref() - .expect("ops is set") - .readiness - .deploy_alone, - Err(DeployAloneRefusal::NoBuildRecord) - )); - - app.handle_kw_ops(InputEvent::EditKwOpsField) - .await - .expect("kw ops handles"); - app.state.kw.ops.as_mut().expect("ops is set").edit_buffer = "built".to_string(); - app.handle_kw_ops(InputEvent::StageKwOpsEdit) - .await - .expect("kw ops handles"); - - let ops = app.state.kw.ops.as_ref().expect("ops is set"); - assert_eq!("built", ops.branch); - assert_eq!(Ok(()), ops.readiness.deploy_alone); - assert_eq!(Some("feature".to_string()), ops.readiness.current_branch); - - shutdown_kw(&app).await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn boot_once_enter_backs_out_without_starting() { - let log_dir = kw_log_dir("boot-once-back-out"); - let process = Arc::new(FakeProcess::new()); - let app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(false), - feature_build_history(Some(matching_feature_build_record())), - ); - let kw = app.services.kw.as_ref().expect("kw is set").clone(); - let (scenes, event_tx, handle) = spawn_app_actor(app); - - event_tx - .send(InputEvent::OpenKwOps) - .await - .expect("open kw ops sends"); - wait_for_kw_ops(&scenes, |_| true).await; - event_tx - .send(InputEvent::StartKwDeploy) - .await - .expect("start kw deploy sends"); - wait_for_latest_popup(&scenes, |popup| { - popup.is_some_and(|popup| popup.title == "Boot into new kernel once?") - }) - .await; - - event_tx - .send(InputEvent::ConfirmPopup) - .await - .expect("confirm popup sends"); - wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; - assert!(process.spawned().is_empty()); - - drop(event_tx); - handle.run_until_done().await.expect("actor finishes"); - kw.shutdown().await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn boot_once_proceed_starts_deploy() { - let log_dir = kw_log_dir("boot-once-proceed"); - let process = Arc::new(FakeProcess::new()); - let app = app_with_kw( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(MockFileSystemTrait::new()), - deploy_kw_fs(false), - feature_build_history(Some(matching_feature_build_record())), - ); - let kw = app.services.kw.as_ref().expect("kw is set").clone(); - let (scenes, event_tx, handle) = spawn_app_actor(app); - - event_tx - .send(InputEvent::OpenKwOps) - .await - .expect("open kw ops sends"); - wait_for_kw_ops(&scenes, |_| true).await; - event_tx - .send(InputEvent::StartKwDeploy) - .await - .expect("start kw deploy sends"); - wait_for_latest_popup(&scenes, |popup| popup.is_some()).await; - - event_tx - .send(InputEvent::NavigateRight) - .await - .expect("navigate right sends"); - event_tx - .send(InputEvent::ConfirmPopup) - .await - .expect("confirm popup sends"); - wait_for_nav(&scenes, |text| text.contains("kw: deploying feature")).await; - wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; - - let spawned = process.spawned(); - assert_eq!(1, spawned.len()); - assert_eq!("deploy", spawned[0].args[0]); - - process.last_child().finish(0); - drop(event_tx); - handle.run_until_done().await.expect("actor finishes"); - kw.shutdown().await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn live_tail_updates_without_input_and_pauses_off_screen() { - let log_dir = kw_log_dir("live-tail"); - let process = Arc::new(FakeProcess::new()); - let app = app_with_details_and_kw_process( - &log_dir, - head_branch_shell("feature"), - process.clone(), - Arc::new(OsFileSystem), - ); - let kw = app.services.kw.as_ref().expect("kw is set").clone(); - let (scenes, event_tx, handle) = spawn_app_actor(app); - - event_tx - .send(InputEvent::OpenKwOps) - .await - .expect("open kw ops sends"); - wait_for_kw_ops(&scenes, |_| true).await; - - event_tx - .send(InputEvent::StartKwBuild) - .await - .expect("start kw build sends"); - wait_for_nav(&scenes, |text| text.contains("kw: building feature")).await; - wait_for_kw_ops(&scenes, |ops| { - ops.log_tail.contains("Waiting for kw output") - }) - .await; - - process.last_child().write_log(b"cc1: compiling foo.c\n"); - wait_for_kw_ops(&scenes, |ops| ops.log_tail.contains("cc1: compiling foo.c")).await; - - event_tx.send(InputEvent::Back).await.expect("back sends"); - wait_for_details(&scenes).await; - wait_for_nav(&scenes, |text| text.contains("kw: building feature")).await; - - let draws_on_details = scene_count(&scenes); - time::sleep(Duration::from_millis(800)).await; - process.last_child().write_log(b"ld: linking vmlinux\n"); - time::sleep(Duration::from_millis(800)).await; - assert_eq!( - draws_on_details, - scene_count(&scenes), - "log ticks must not redraw while away from KwOps" - ); - - event_tx - .send(InputEvent::ToggleBookmark) - .await - .expect("toggle bookmark sends"); - time::timeout(Duration::from_secs(5), async { - loop { - if scene_count(&scenes) > draws_on_details { - break; - } - time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("input must still redraw off KwOps"); - - event_tx - .send(InputEvent::OpenKwOps) - .await - .expect("open kw ops sends"); - wait_for_kw_ops(&scenes, |ops| { - ops.log_tail.contains("cc1: compiling foo.c") - && ops.log_tail.contains("ld: linking vmlinux") - }) - .await; - - process.last_child().finish(0); - wait_for_kw_ops(&scenes, |ops| ops.job_status.contains("succeeded")).await; - wait_for_kw_ops(&scenes, |ops| { - ops.log_tail.contains("cc1: compiling foo.c") - && ops.log_tail.contains("ld: linking vmlinux") - }) - .await; - - let draws_after_success = scene_count(&scenes); - time::sleep(Duration::from_millis(800)).await; - assert_eq!( - draws_after_success, - scene_count(&scenes), - "log ticks must stop after the job finishes" + assert_eq!(expected_title, title); + assert!( + body.contains(fragment), + "expected popup body to contain {fragment:?}, got {body:?}" ); - - drop(event_tx); - handle.run_until_done().await.expect("actor finishes"); - kw.shutdown().await; - fs::remove_dir_all(&log_dir).expect("temp dir removes"); } - - fn app_with_details_and_kw(log_dir: &Path, kw_shell: MockShellTrait) -> App { + pub fn app_with_details_and_kw(log_dir: &Path, kw_shell: MockShellTrait) -> App { app_with_details_and_kw_process( log_dir, kw_shell, @@ -806,7 +122,7 @@ mod unix { ) } - fn app_with_details_and_kw_process( + pub fn app_with_details_and_kw_process( log_dir: &Path, kw_shell: MockShellTrait, process: Arc, @@ -822,7 +138,7 @@ mod unix { ) } - fn app_with_kw( + pub fn app_with_kw( log_dir: &Path, kw_shell: MockShellTrait, process: Arc, @@ -859,7 +175,7 @@ mod unix { app } - fn default_kw_history() -> MockKwHistoryStore { + pub fn default_kw_history() -> MockKwHistoryStore { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() @@ -879,7 +195,7 @@ mod unix { history } - fn feature_build_history(record: Option) -> MockKwHistoryStore { + pub fn feature_build_history(record: Option) -> MockKwHistoryStore { let mut history = MockKwHistoryStore::new(); history .expect_apply_record_for_branch() @@ -905,11 +221,11 @@ mod unix { history } - fn matching_feature_build_record() -> KwBuildRecord { + pub fn matching_feature_build_record() -> KwBuildRecord { matching_build_record("feature") } - fn matching_build_record(branch: &str) -> KwBuildRecord { + pub fn matching_build_record(branch: &str) -> KwBuildRecord { KwBuildRecord { kernel_tree_id: "linux".to_string(), tree_path: "/kernel".to_string(), @@ -925,7 +241,7 @@ mod unix { } } - fn deploy_kw_fs(boot_once_off: bool) -> MockFileSystemTrait { + pub fn deploy_kw_fs(boot_once_off: bool) -> MockFileSystemTrait { let deploy_config = if boot_once_off { "boot_into_new_kernel_once=no\n".to_string() } else { @@ -1020,7 +336,7 @@ mod unix { fs } - fn head_branch_shell(branch: &str) -> MockShellTrait { + pub fn head_branch_shell(branch: &str) -> MockShellTrait { let branch = branch.to_string(); let mut shell = MockShellTrait::new(); shell @@ -1056,7 +372,7 @@ mod unix { shell } - fn kw_actor_fs() -> MockFileSystemTrait { + pub fn kw_actor_fs() -> MockFileSystemTrait { let mut fs = MockFileSystemTrait::new(); fs.expect_is_dir() .withf(|path| { @@ -1123,7 +439,7 @@ mod unix { fs } - fn kw_actor_env() -> MockEnvTrait { + pub fn kw_actor_env() -> MockEnvTrait { let mut env = MockEnvTrait::new(); env.expect_which() .withf(|name| name == "kw") @@ -1132,7 +448,7 @@ mod unix { env } - fn apply_config() -> ConfigSnapshot { + pub fn apply_config() -> ConfigSnapshot { ConfigSnapshot::from( &serde_json::from_value::(serde_json::json!({ "kernel_trees": { @@ -1147,13 +463,13 @@ mod unix { ) } - async fn shutdown_kw(app: &App) { + pub async fn shutdown_kw(app: &App) { if let Some(kw) = &app.services.kw { kw.shutdown().await; } } - fn kw_log_dir(test_name: &str) -> PathBuf { + pub fn kw_log_dir(test_name: &str) -> PathBuf { let n = LOG_DIR_SEQ.fetch_add(1, Ordering::SeqCst); let dir = env::temp_dir().join(format!( "patch-hub-app-kw-ops-{}-{}-{n}", @@ -1165,7 +481,7 @@ mod unix { dir } - fn spawn_app_actor( + pub fn spawn_app_actor( app: App, ) -> ( Arc>>, @@ -1198,7 +514,7 @@ mod unix { (scenes, event_tx, handle) } - async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str) -> bool) { + pub async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str) -> bool) { time::timeout(Duration::from_secs(5), async { loop { if scenes @@ -1216,7 +532,7 @@ mod unix { .expect("expected navigation text did not appear"); } - async fn wait_for_kw_ops( + pub async fn wait_for_kw_ops( scenes: &Arc>>, predicate: impl Fn(&KwOpsScene) -> bool, ) { @@ -1235,7 +551,7 @@ mod unix { .expect("expected KwOps scene did not appear"); } - async fn wait_for_latest_popup( + pub async fn wait_for_latest_popup( scenes: &Arc>>, predicate: impl Fn(Option<&PopupScene>) -> bool, ) { @@ -1256,7 +572,7 @@ mod unix { .expect("expected popup state did not appear"); } - async fn wait_for_details(scenes: &Arc>>) { + pub async fn wait_for_details(scenes: &Arc>>) { time::timeout(Duration::from_secs(5), async { loop { if scenes @@ -1274,11 +590,11 @@ mod unix { .expect("expected Patchset Details scene did not appear"); } - fn scene_count(scenes: &Arc>>) -> usize { + pub fn scene_count(scenes: &Arc>>) -> usize { scenes.lock().expect("scenes locks").len() } - fn nav_text(scene: &UiScene) -> String { + pub fn nav_text(scene: &UiScene) -> String { scene .navigation .mode_spans @@ -1287,3 +603,689 @@ mod unix { .collect() } } +pub use helpers::*; + +#[tokio::test] +async fn open_kw_ops_prefills_branch_from_head_not_tree_config() { + let log_dir = kw_log_dir("open-head"); + let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); + + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); + let ops = app.state.kw.ops.as_ref().expect("KwOps state"); + assert_eq!("feature", ops.branch); + assert_eq!("main", ops.tree.branch()); + assert!(!ops.head_unreadable); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn start_rejects_empty_branch() { + let log_dir = kw_log_dir("empty-branch"); + let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("")); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + assert!(app + .state + .kw + .ops + .as_ref() + .is_some_and(|ops| ops.branch.is_empty())); + + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); + assert_info_popup( + app.state.popup.as_ref(), + "Cannot start build", + "detached or unverifiable", + ); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn start_build_accepts_and_cancel_sets_requested() { + let log_dir = kw_log_dir("start-cancel"); + let process = Arc::new(FakeProcess::new()); + let mut app = app_with_details_and_kw_process( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); + assert!(app.state.popup.is_none()); + let spawned = process.spawned(); + assert_eq!(1, spawned.len()); + assert_eq!(vec!["build"], spawned[0].args); + + let kw = app.services.kw.as_ref().expect("kw is set").clone(); + let status = kw.get_status().await.expect("status loads"); + app.state.kw.status = Some(status); + assert!(matches!( + app.state.kw.status.as_ref().map(|s| &s.job), + Some(KwJobStatus::Running { .. }) + )); + + app.handle_kw_ops(InputEvent::CancelKwJob) + .await + .expect("kw ops handles"); + assert!(app + .state + .kw + .ops + .as_ref() + .is_some_and(|ops| ops.cancel_requested)); + + process.last_child().finish(0); + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn second_start_while_busy_is_absorbed() { + let log_dir = kw_log_dir("double-start"); + let process = Arc::new(FakeProcess::new()); + let mut app = app_with_details_and_kw_process( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); + assert!(app.state.popup.is_none()); + assert!(matches!( + app.state.kw.status.as_ref().map(|s| &s.job), + Some(KwJobStatus::Running { .. }) + )); + assert!(!app + .state + .kw + .ops + .as_ref() + .is_some_and(|ops| ops.start_requested)); + + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); + assert!(app.state.popup.is_none()); + assert_eq!(1, process.spawned().len()); + + process.last_child().finish(0); + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn fallback_seeds_a_running_job_when_projection_is_empty() { + let log_dir = kw_log_dir("fallback-seed"); + let process = Arc::new(FakeProcess::new()); + let mut app = app_with_details_and_kw_process( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + app.handle_kw_ops(InputEvent::StartKwBuild) + .await + .expect("kw ops handles"); + app.state.kw.status = None; + + app.fallback_kw_status().await; + assert!(matches!( + app.state.kw.status.as_ref().map(|s| &s.job), + Some(KwJobStatus::Running { .. }) + )); + + process.last_child().finish(0); + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn refresh_log_tail_goes_through_injected_fs() { + let log_dir = kw_log_dir("injected-fs"); + let mut fs = MockFileSystemTrait::new(); + fs.expect_read_tail_to_string() + .withf(|path, _max_bytes| path.extension() == Some("log".as_ref())) + .times(1) + .returning(|_, _| Ok("cc1: compiling from mock\n".to_string())); + let mut app = app_with_details_and_kw_process( + &log_dir, + head_branch_shell("feature"), + Arc::new(FakeProcess::new()), + Arc::new(fs), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + app.state.kw.status = Some(KwStatusSnapshot { + job: KwJobStatus::Running { + kind: KwJobKind::Build, + phase: KwPhase::Building, + kernel_tree_id: "linux".to_string(), + branch: "feature".to_string(), + log_path: log_dir.join("build.log"), + }, + restore_branch: None, + }); + + app.refresh_kw_ops_log_tail().await; + assert!(app + .state + .kw + .ops + .as_ref() + .is_some_and(|ops| ops.log_tail.contains("from mock"))); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn reenter_keeps_typed_extras() { + let log_dir = kw_log_dir("reenter-extras"); + let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + app.state.kw.ops.as_mut().expect("ops is set").extra_args = "--verbose".to_string(); + + app.handle_kw_ops(InputEvent::Back) + .await + .expect("kw ops handles"); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + assert_eq!( + "--verbose", + app.state.kw.ops.as_ref().expect("ops is set").extra_args + ); + assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn back_returns_to_details() { + let log_dir = kw_log_dir("back"); + let mut app = app_with_details_and_kw(&log_dir, head_branch_shell("feature")); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + app.handle_kw_ops(InputEvent::Back) + .await + .expect("kw ops handles"); + assert_eq!( + CurrentScreen::PatchsetDetails, + app.state.navigation.current_screen + ); + assert!(app.state.lore.details.is_some()); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn open_kw_ops_projects_the_resolved_remote() { + let log_dir = kw_log_dir("remote-display"); + let mut app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + Arc::new(FakeProcess::new()), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(true), + default_kw_history(), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + let ops = app.state.kw.ops.as_ref().expect("KwOps state"); + assert_eq!( + "root@box:22", + ops.readiness + .deploy_remote + .as_ref() + .expect("deploy remote is set") + .endpoint() + ); + assert_eq!(BootOnceState::Off, ops.readiness.boot_once); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn start_deploy_opens_the_boot_once_gate() { + let log_dir = kw_log_dir("deploy-gate"); + let process = Arc::new(FakeProcess::new()); + let mut app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(false), + feature_build_history(Some(matching_feature_build_record())), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + app.handle_kw_ops(InputEvent::StartKwDeploy) + .await + .expect("kw ops handles"); + + let Some(AppPopup::Confirm { title, .. }) = app.state.popup.as_ref() else { + panic!("expected boot-once confirm popup"); + }; + assert_eq!("Boot into new kernel once?", title); + assert_eq!( + Some(DeployStartKind::Deploy), + app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .pending_deploy + ); + assert!(process.spawned().is_empty()); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn start_deploy_refused_without_a_build_record() { + let log_dir = kw_log_dir("deploy-no-record"); + let process = Arc::new(FakeProcess::new()); + let mut app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(true), + default_kw_history(), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + app.handle_kw_ops(InputEvent::StartKwDeploy) + .await + .expect("kw ops handles"); + assert_info_popup( + app.state.popup.as_ref(), + "Cannot start deploy", + "no build recorded", + ); + assert!(process.spawned().is_empty()); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn start_deploy_accepts_when_ready() { + let log_dir = kw_log_dir("deploy-ready"); + let process = Arc::new(FakeProcess::new()); + let mut app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(true), + feature_build_history(Some(matching_feature_build_record())), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + app.handle_kw_ops(InputEvent::StartKwDeploy) + .await + .expect("kw ops handles"); + assert!(app.state.popup.is_none()); + let spawned = process.spawned(); + assert_eq!(1, spawned.len()); + assert_eq!( + [ + "deploy", + "--remote", + "root@box:22", + "--no-reboot", + "--force" + ] + .as_slice(), + spawned[0].args.as_slice() + ); + assert!(matches!( + app.state.kw.status.as_ref().map(|s| &s.job), + Some(KwJobStatus::Running { + kind: KwJobKind::Deploy, + phase: KwPhase::Deploying, + .. + }) + )); + + process.last_child().finish(0); + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn start_build_then_deploy_chains_two_processes() { + let log_dir = kw_log_dir("build-then-deploy"); + let process = Arc::new(FakeProcess::new()); + let mut app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(true), + default_kw_history(), + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + + app.handle_kw_ops(InputEvent::StartKwBuildThenDeploy) + .await + .expect("kw ops handles"); + assert!(app.state.popup.is_none()); + assert_eq!(vec!["build"], process.spawned()[0].args); + + process.last_child().finish(0); + time::timeout(Duration::from_secs(5), async { + loop { + if process.spawned().len() >= 2 { + return; + } + time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("deploy should spawn after a successful build"); + + let spawned = process.spawned(); + assert_eq!("deploy", spawned[1].args[0]); + assert!(spawned[1].args.iter().any(|arg| arg == "root@box:22")); + + process.last_child().finish(0); + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +async fn committing_a_branch_reprobes_deploy_alone() { + let log_dir = kw_log_dir("branch-reprobe"); + let mut history = MockKwHistoryStore::new(); + history + .expect_apply_record_for_branch() + .withf(|_, _| true) + .times(0) + .returning(|_, _| Ok(None)); + history + .expect_record_build() + .withf(|_| true) + .times(0) + .returning(|_| Ok(())); + history + .expect_build_records() + .withf(|tree, branch| tree == "linux" && (branch == "feature" || branch == "built")) + .times(2) + .returning(|_, branch| { + if branch == "built" { + Ok(( + Some(matching_build_record("built")), + Some(matching_build_record("built")), + )) + } else { + Ok((None, None)) + } + }); + let mut app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + Arc::new(FakeProcess::new()), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(true), + history, + ); + app.handle_patchset_details(InputEvent::OpenKwOps, &dummy_terminal_handle()) + .await + .expect("patchset details handles"); + assert!(matches!( + app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .readiness + .deploy_alone, + Err(DeployAloneRefusal::NoBuildRecord) + )); + + app.handle_kw_ops(InputEvent::EditKwOpsField) + .await + .expect("kw ops handles"); + app.state.kw.ops.as_mut().expect("ops is set").edit_buffer = "built".to_string(); + app.handle_kw_ops(InputEvent::StageKwOpsEdit) + .await + .expect("kw ops handles"); + + let ops = app.state.kw.ops.as_ref().expect("ops is set"); + assert_eq!("built", ops.branch); + assert_eq!(Ok(()), ops.readiness.deploy_alone); + assert_eq!(Some("feature".to_string()), ops.readiness.current_branch); + + shutdown_kw(&app).await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn boot_once_enter_backs_out_without_starting() { + let log_dir = kw_log_dir("boot-once-back-out"); + let process = Arc::new(FakeProcess::new()); + let app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(false), + feature_build_history(Some(matching_feature_build_record())), + ); + let kw = app.services.kw.as_ref().expect("kw is set").clone(); + let (scenes, event_tx, handle) = spawn_app_actor(app); + + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); + wait_for_kw_ops(&scenes, |_| true).await; + event_tx + .send(InputEvent::StartKwDeploy) + .await + .expect("start kw deploy sends"); + wait_for_latest_popup(&scenes, |popup| { + popup.is_some_and(|popup| popup.title == "Boot into new kernel once?") + }) + .await; + + event_tx + .send(InputEvent::ConfirmPopup) + .await + .expect("confirm popup sends"); + wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; + assert!(process.spawned().is_empty()); + + drop(event_tx); + handle.run_until_done().await.expect("actor finishes"); + kw.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn boot_once_proceed_starts_deploy() { + let log_dir = kw_log_dir("boot-once-proceed"); + let process = Arc::new(FakeProcess::new()); + let app = app_with_kw( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(MockFileSystemTrait::new()), + deploy_kw_fs(false), + feature_build_history(Some(matching_feature_build_record())), + ); + let kw = app.services.kw.as_ref().expect("kw is set").clone(); + let (scenes, event_tx, handle) = spawn_app_actor(app); + + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); + wait_for_kw_ops(&scenes, |_| true).await; + event_tx + .send(InputEvent::StartKwDeploy) + .await + .expect("start kw deploy sends"); + wait_for_latest_popup(&scenes, |popup| popup.is_some()).await; + + event_tx + .send(InputEvent::NavigateRight) + .await + .expect("navigate right sends"); + event_tx + .send(InputEvent::ConfirmPopup) + .await + .expect("confirm popup sends"); + wait_for_nav(&scenes, |text| text.contains("kw: deploying feature")).await; + wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; + + let spawned = process.spawned(); + assert_eq!(1, spawned.len()); + assert_eq!("deploy", spawned[0].args[0]); + + process.last_child().finish(0); + drop(event_tx); + handle.run_until_done().await.expect("actor finishes"); + kw.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn live_tail_updates_without_input_and_pauses_off_screen() { + let log_dir = kw_log_dir("live-tail"); + let process = Arc::new(FakeProcess::new()); + let app = app_with_details_and_kw_process( + &log_dir, + head_branch_shell("feature"), + process.clone(), + Arc::new(OsFileSystem), + ); + let kw = app.services.kw.as_ref().expect("kw is set").clone(); + let (scenes, event_tx, handle) = spawn_app_actor(app); + + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); + wait_for_kw_ops(&scenes, |_| true).await; + + event_tx + .send(InputEvent::StartKwBuild) + .await + .expect("start kw build sends"); + wait_for_nav(&scenes, |text| text.contains("kw: building feature")).await; + wait_for_kw_ops(&scenes, |ops| { + ops.log_tail.contains("Waiting for kw output") + }) + .await; + + process.last_child().write_log(b"cc1: compiling foo.c\n"); + wait_for_kw_ops(&scenes, |ops| ops.log_tail.contains("cc1: compiling foo.c")).await; + + event_tx.send(InputEvent::Back).await.expect("back sends"); + wait_for_details(&scenes).await; + wait_for_nav(&scenes, |text| text.contains("kw: building feature")).await; + + let draws_on_details = scene_count(&scenes); + time::sleep(Duration::from_millis(800)).await; + process.last_child().write_log(b"ld: linking vmlinux\n"); + time::sleep(Duration::from_millis(800)).await; + assert_eq!( + draws_on_details, + scene_count(&scenes), + "log ticks must not redraw while away from KwOps" + ); + + event_tx + .send(InputEvent::ToggleBookmark) + .await + .expect("toggle bookmark sends"); + time::timeout(Duration::from_secs(5), async { + loop { + if scene_count(&scenes) > draws_on_details { + break; + } + time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("input must still redraw off KwOps"); + + event_tx + .send(InputEvent::OpenKwOps) + .await + .expect("open kw ops sends"); + wait_for_kw_ops(&scenes, |ops| { + ops.log_tail.contains("cc1: compiling foo.c") + && ops.log_tail.contains("ld: linking vmlinux") + }) + .await; + + process.last_child().finish(0); + wait_for_kw_ops(&scenes, |ops| ops.job_status.contains("succeeded")).await; + wait_for_kw_ops(&scenes, |ops| { + ops.log_tail.contains("cc1: compiling foo.c") + && ops.log_tail.contains("ld: linking vmlinux") + }) + .await; + + let draws_after_success = scene_count(&scenes); + time::sleep(Duration::from_millis(800)).await; + assert_eq!( + draws_after_success, + scene_count(&scenes), + "log ticks must stop after the job finishes" + ); + + drop(event_tx); + handle.run_until_done().await.expect("actor finishes"); + kw.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} From ed142d8b67dd97fa54d7784e37adee57b5579acd Mon Sep 17 00:00:00 2001 From: lorenzoberts Date: Sun, 4 Oct 2026 15:44:18 -0300 Subject: [PATCH 61/61] chore(lints): share clippy lints across the workspace The root lint tables move under [workspace.lints] and both packages inherit them. The proc-macro crate reports bad input with syn::Error and tests use expect. Signed-off-by: lorenzoberts --- Cargo.toml | 18 ++-- patch_hub_proc_macros/Cargo.toml | 3 + patch_hub_proc_macros/src/lib.rs | 88 ++++++++++++------- .../tests/serde_individual_default.rs | 14 +-- 4 files changed, 77 insertions(+), 46 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index de147a6..3794502 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,19 +54,21 @@ targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] # Publish jobs to run in CI pr-run-mode = "plan" -# Linter configurations -[lints.rust] +[lints] +workspace = true + +[workspace] +members = [ + "patch_hub_proc_macros", +] + +[workspace.lints.rust] warnings = "deny" unconditional_recursion = "deny" -[lints.clippy] +[workspace.lints.clippy] map_unwrap_or = "deny" unwrap_used = "deny" allow_attributes = "deny" redundant_type_annotations = "warn" match_wildcard_for_single_variants = "warn" - -[workspace] -members = [ - "patch_hub_proc_macros", -] diff --git a/patch_hub_proc_macros/Cargo.toml b/patch_hub_proc_macros/Cargo.toml index f2b6076..eb7a3c1 100644 --- a/patch_hub_proc_macros/Cargo.toml +++ b/patch_hub_proc_macros/Cargo.toml @@ -17,3 +17,6 @@ proc-macro2 = "1.0.95" [lib] proc-macro = true doctest = false # otherwise tests will fail + +[lints] +workspace = true diff --git a/patch_hub_proc_macros/src/lib.rs b/patch_hub_proc_macros/src/lib.rs index 61bc63a..4dc76a2 100644 --- a/patch_hub_proc_macros/src/lib.rs +++ b/patch_hub_proc_macros/src/lib.rs @@ -1,7 +1,7 @@ extern crate proc_macro; use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{parse_macro_input, Data, DeriveInput}; +use syn::{Data, DeriveInput, Fields}; /// This procedural macro create default deserealization functions for each /// structure attribute based on std::Default impl. @@ -36,19 +36,40 @@ use syn::{parse_macro_input, Data, DeriveInput}; /// "test_1": 500, /// "test_2": 100 /// }); -/// let example_struct_1: Example = serde_json::from_value(json_data_1).unwrap(); +/// let example_struct_1: Example = serde_json::from_value(json_data_1) +/// .expect("example json deserializes"); /// assert_eq!(example_struct_1.test_1, 500); /// assert_eq!(example_struct_1.test_2, 100); /// assert_eq!(example_struct_1.test_3, "a".to_string()); /// ``` #[proc_macro_attribute] pub fn serde_individual_default(_attr: TokenStream, input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input as DeriveInput); + match expand_serde_individual_default(input) { + Ok(tokens) => tokens, + Err(error) => error.to_compile_error().into(), + } +} + +fn expand_serde_individual_default(input: TokenStream) -> syn::Result { + let input = syn::parse::(input)?; let struct_name = &input.ident; let struct_generics = &input.generics; let struct_fields = match &input.data { - Data::Struct(s) => &s.fields, - _ => panic!("SerdeIndividualDefault can only be used with structs"), + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => fields, + Fields::Unnamed(_) | Fields::Unit => { + return Err(syn::Error::new_spanned( + struct_name, + "SerdeIndividualDefault can only be used with structs that have named fields", + )); + } + }, + Data::Enum(_) | Data::Union(_) => { + return Err(syn::Error::new_spanned( + struct_name, + "SerdeIndividualDefault can only be used with structs", + )); + } }; let struct_attrs = &input.attrs; let struct_visibility = &input.vis; @@ -69,36 +90,37 @@ pub fn serde_individual_default(_attr: TokenStream, input: TokenStream) -> Token }; // build struct attributes with #[serde(default = "")] and build the default function itself - let (all_field_attrs, default_deserialize_function_definitions) = struct_fields.iter().fold( - (vec![], vec![]), - |(mut all_field_attrs, mut default_deserialize_function_definitions), field| { - let field_name = &field.ident; - let field_type = &field.ty; - let field_vis = &field.vis; - let field_attrs = &field.attrs; - let field_name_str = field_name.as_ref().unwrap().to_string(); - - // default function name will be named default_{struct_name}_{field_name} - let default_deserialize_function_name = - format_ident!("default_{}_{}", struct_name_str, field_name_str); + let mut all_field_attrs = Vec::new(); + let mut default_deserialize_function_definitions = Vec::new(); + for field in &struct_fields.named { + let field_name = field.ident.as_ref().ok_or_else(|| { + syn::Error::new_spanned( + field, + "SerdeIndividualDefault requires every field to be named", + ) + })?; + let field_type = &field.ty; + let field_vis = &field.vis; + let field_attrs = &field.attrs; + let field_name_str = field_name.to_string(); - let default_deserialize_function_name_str = - default_deserialize_function_name.to_string(); + // default function name will be named default_{struct_name}_{field_name} + let default_deserialize_function_name = + format_ident!("default_{}_{}", struct_name_str, field_name_str); - all_field_attrs.push(quote! { - #(#field_attrs)* - #[serde(default = #default_deserialize_function_name_str)] - #field_vis #field_name: #field_type, - }); - default_deserialize_function_definitions.push(quote! { - fn #default_deserialize_function_name() -> #field_type { - #default_config_struct_name.#field_name.clone() - } - }); + let default_deserialize_function_name_str = default_deserialize_function_name.to_string(); - (all_field_attrs, default_deserialize_function_definitions) - }, - ); + all_field_attrs.push(quote! { + #(#field_attrs)* + #[serde(default = #default_deserialize_function_name_str)] + #field_vis #field_name: #field_type, + }); + default_deserialize_function_definitions.push(quote! { + fn #default_deserialize_function_name() -> #field_type { + #default_config_struct_name.#field_name.clone() + } + }); + } // build final struct. //We have to explicitly derive Deserialize here so the serde attribute works @@ -113,5 +135,5 @@ pub fn serde_individual_default(_attr: TokenStream, input: TokenStream) -> Token #(#default_deserialize_function_definitions)* }; - TokenStream::from(expanded_token_stream) + Ok(TokenStream::from(expanded_token_stream)) } diff --git a/patch_hub_proc_macros/tests/serde_individual_default.rs b/patch_hub_proc_macros/tests/serde_individual_default.rs index 315e8ea..2218fd2 100644 --- a/patch_hub_proc_macros/tests/serde_individual_default.rs +++ b/patch_hub_proc_macros/tests/serde_individual_default.rs @@ -62,7 +62,8 @@ fn should_have_default_serialization() { "test_2": 100 }); - let example_struct_1: Example = serde_json::from_value(json_data_1).unwrap(); + let example_struct_1: Example = + serde_json::from_value(json_data_1).expect("example with a missing field deserializes"); // Assert that`test_1` and `test_2` are set to the custom value assert_eq!(example_struct_1.test_1, 500); @@ -79,7 +80,8 @@ fn should_have_default_serialization() { "test_3": "test".to_string() }); - let example_struct_2: Example = serde_json::from_value(json_data_2).unwrap(); + let example_struct_2: Example = serde_json::from_value(json_data_2) + .expect("example with another missing field deserializes"); // Assert that`test_1` and `test_3` are set to the custom value assert_eq!(example_struct_2.test_1, 999); @@ -98,7 +100,8 @@ fn should_preserve_other_attributes() { "test_3": "b".to_string() }); - let example_struct: Example = serde_json::from_value(json_data).unwrap(); + let example_struct: Example = + serde_json::from_value(json_data).expect("fully populated example deserializes"); // Assert that`test_2` and `test_3` have getters assert_eq!(example_struct.test_1, 500); @@ -113,7 +116,7 @@ fn test_struct_without_serialize() { }); let example_without_serialize: ExampleWithoutSerialize = - serde_json::from_value(json_data).unwrap(); + serde_json::from_value(json_data).expect("struct without Serialize deserializes"); assert_eq!(example_without_serialize.test_1, 765); assert_eq!(example_without_serialize.test_2, 123); @@ -125,7 +128,8 @@ fn test_public_struct() { "test_1": 345, }); - let example_public: ExamplePublic = serde_json::from_value(json_data).unwrap(); + let example_public: ExamplePublic = + serde_json::from_value(json_data).expect("public struct deserializes"); assert_eq!(example_public.test_1, 345); assert_eq!(example_public.test_2, 403);