diff --git a/.github/workflows/build_and_unit_test.yml b/.github/workflows/build_and_unit_test.yml index b788c925..0377d7c8 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/format_and_lint.yml b/.github/workflows/format_and_lint.yml index 5edbd932..a7e7d255 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/.github/workflows/release.yml b/.github/workflows/release.yml index a947bb20..12c1fd4e 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index c22f8689..36da6e1c 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] diff --git a/Cargo.toml b/Cargo.toml index 16d15968..37945028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,16 +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] -warnings = "deny" -unconditional_recursion = "deny" - -[lints.clippy] -too-many-arguments = "allow" -map_unwrap_or = "deny" +[lints] +workspace = true [workspace] members = [ "patch_hub_proc_macros", ] + +[workspace.lints.rust] +warnings = "deny" +unconditional_recursion = "deny" + +[workspace.lints.clippy] +map_unwrap_or = "deny" +unwrap_used = "deny" +allow_attributes = "deny" +redundant_type_annotations = "warn" +match_wildcard_for_single_variants = "warn" diff --git a/patch_hub_proc_macros/Cargo.toml b/patch_hub_proc_macros/Cargo.toml index f2b6076b..eb7a3c12 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 61bc63a2..4dc76a21 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 315e8ea6..2218fd23 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); diff --git a/src/app/actions/apply.rs b/src/app/actions/apply.rs index 85c2d32b..fc7621bf 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,346 +58,418 @@ 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}'")); - }; - - 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())); +impl ApplyPatchsetService { + 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())); + } + + 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)] mod tests { - use std::{ - collections::VecDeque, - sync::{Arc, Mutex}, - }; - - use crate::{ - config::{ConfigSnapshot, ConfigState}, - infrastructure::{ - file_system::MockFileSystemTrait, - shell::{MockShellTrait, ShellCommand, ShellOutput}, - }, - }; - use super::*; - - 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 { - serde_json::from_value::(serde_json::json!({ - "kernel_trees": { - "linux": { - "path": KERNEL_TREE_PATH, - "branch": BASE_BRANCH - } + mod helpers { + use super::super::*; + use crate::{ + config::{ConfigSnapshot, ConfigState}, + infrastructure::{ + file_system::MockFileSystemTrait, + shell::{MockShellTrait, ShellCommand, ShellOutput}, }, - "target_kernel_tree": "linux", - "git_am_options": "--signoff --3way", - "git_am_branch_prefix": "patchset-" - })) - .expect("test config should deserialize") - .to_snapshot() - } + }; + use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + }; + + 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 { - 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() - } + 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 { - ConfigState::default().to_snapshot() - } + 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() - .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); - fs.expect_is_file().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().returning(move |cmd| { - calls_for_execute.lock().unwrap().push(command_parts(cmd)); - Ok(outputs_for_execute - .lock() - .unwrap() - .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() { @@ -408,7 +483,8 @@ mod tests { output("", "", true), ]); - let applied = 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 @@ -418,7 +494,8 @@ mod tests { assert!(applied .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], @@ -483,10 +560,12 @@ 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()) + .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], @@ -499,21 +578,24 @@ 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()) + .expect_err("dirty worktree rejects"); 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] 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 = apply_patchset(&request(), &fs, &shell, &config_without_target()).unwrap_err(); + let result = + ApplyPatchsetService::apply_patchset(&request(), &fs, &shell, &config_without_target()) + .expect_err("missing tree rejects"); assert_eq!("target kernel tree unset", result); } @@ -526,10 +608,11 @@ 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()) + .expect_err("invalid branch rejects"); assert!(result.contains("invalid branch 'main'")); - assert_eq!(2, calls.lock().unwrap().len()); + assert_eq!(2, calls.lock().expect("calls locks").len()); } #[test] @@ -548,11 +631,13 @@ mod tests { output("", "", true), ]); - let result = 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")); - let calls = calls.lock().unwrap(); + + let calls = calls.lock().expect("calls locks"); assert_eq!(9, calls.len()); assert_eq!( &calls[6], @@ -584,7 +669,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()) + .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/actions/mod.rs b/src/app/actions/mod.rs index ee35f68a..364698c5 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,13 +36,13 @@ impl<'a> PatchsetActionService<'a> { request: &ApplyPatchsetRequest, config: &ConfigSnapshot, ) -> Result { - apply::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::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/actions/reviewed_reply.rs b/src/app/actions/reviewed_reply.rs index 25a7ba7f..7b1ef4c0 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 } @@ -30,106 +30,119 @@ 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) = 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 }); + }; + + 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, + ); - 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 }) + 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 = 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]); +impl ReviewedReplyService { + 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}>")) + } } } #[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 = 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 +151,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] @@ -152,11 +174,15 @@ 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]); - record_successful_reply_indexes( + ReviewedReplyService::record_successful_reply_indexes( &shell, &mut successful_indexes, &[false, true, false, true], @@ -171,11 +197,12 @@ 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(); - record_successful_reply_indexes( + ReviewedReplyService::record_successful_reply_indexes( &shell, &mut successful_indexes, &[true], @@ -197,11 +224,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/actor.rs b/src/app/actor.rs index e3e04581..0063cf86 100644 --- a/src/app/actor.rs +++ b/src/app/actor.rs @@ -1,36 +1,22 @@ -//! 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`](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 -//! 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. -use std::{ops::ControlFlow, time::Duration}; - -use color_eyre::{eyre::eyre, Result}; -use tokio::{spawn, sync::mpsc, sync::watch, time::MissedTickBehavior}; +//! 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::{future::pending, ops::ControlFlow, time::Duration}; + +use color_eyre::{eyre::eyre, Report, Result}; +use tokio::{ + spawn, + sync::{mpsc, watch}, + time::{self, 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}, + loading::TerminalLoadingIndicator, + models::popup::{AppPopup, ConfirmAction}, screens::CurrentScreen, App, }, @@ -39,17 +25,15 @@ use crate::{ errors::KwError, status::{KwJobStatus, KwStatusSnapshot}, }, - terminal::{handle::TerminalHandle, messages::TerminalFrame}, + terminal::{handle::TerminalHandle, messages::TerminalFrame, TerminalError}, ui::handle::UiHandle, }; -/// Owns `App` state and drives the main application loop on a dedicated task. +/// Owns `App` state and drives the main loop on a dedicated task. /// -/// Constructed via [`AppActor::spawn`], which moves all owned resources into -/// the actor and returns an [`AppHandle`] to the caller. -/// -/// 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, @@ -85,17 +69,17 @@ 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"); 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; @@ -114,17 +98,22 @@ 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 = 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 @@ -142,13 +131,13 @@ 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) => { - 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 +145,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 +153,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 +176,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,249 +184,355 @@ 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 } } } -} -/// 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, + fn convert_terminal_error(error: TerminalError) -> Report { + eyre!("{error}") } } -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 { .. }) - ) -} +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 => pending().await, + } + } -/// 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 && Self::is_kw_job_running(app) + } -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 handle_mailing_list_selection(app, 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 => { - handle_bookmarked_patchsets(app, input, loading).await?; - } - CurrentScreen::PatchsetDetails => { - handle_patchset_details(app, input, terminal_handle).await?; - } - CurrentScreen::EditConfig => { - handle_edit_config(app, input).await?; - } - CurrentScreen::LatestPatchsets => { - handle_latest_patchsets(app, input, loading).await?; - } - CurrentScreen::KwOps => { - handle_kw_ops(app, 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) { - clear_pending_deploy(app); + 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 => { - resume_pending_deploy(app).await?; - Ok(ControlFlow::Continue(())) + 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(); } - ConfirmAction::BackOut => { - clear_pending_deploy(app); - Ok(ControlFlow::Continue(())) + app.state.popup = None; + } + + 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(())) + } + ConfirmAction::BackOut => { + app.clear_pending_deploy(); + Ok(ControlFlow::Continue(())) + } } } -} -fn kw_job_is_running(app: &App) -> bool { - matches!( - app.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) -} + fn is_kw_job_running(app: &App) -> bool { + 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 -/// 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"); + /// 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)] 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, ConfigState}, - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - input::{event::InputEvent, handle::InputHandle, messages::InputMessage}, - kw::history::MockKwHistoryStore, - lore::{ - application::{ - actor::LoreApiActor, cache::CacheTtl, handle::LoreApiHandle, 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::*; + 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, + }, + user_state: UserLoreState { + bookmarked_patchsets: BookmarkedPatchsetsState { + bookmarked_patchsets: vec![], + patchset_index: 0, + }, + reviewed_patchsets: HashMap::new(), }, - latest_patchsets: None, - details: None, + config_state: ConfigUiState { edit_config: None }, + config: ConfigSnapshot::from(&ConfigState::default()), + popup: None, + kw: Default::default(), }, - user_state: UserLoreState { - bookmarked_patchsets: BookmarkedPatchsetsState { - bookmarked_patchsets: vec![], - patchset_index: 0, + 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, + }, + } + } + + 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, + }, + tree: TreeReadiness::Ready { + arch: Some("x86_64".to_string()), }, - reviewed_patchsets: HashMap::new(), + 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: ConfigState::default().to_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: 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() { @@ -470,40 +565,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() - .returning(move || Ok(vec![list.clone()])); - let mut user_state = MockUserLoreStateStore::new(); - user_state - .expect_load_bookmarked_patchsets() - .returning(|| Ok(vec![])); - user_state - .expect_load_reviewed_patchsets() - .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. @@ -529,7 +590,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()), @@ -556,50 +617,19 @@ mod tests { render.shutdown().await; } - fn sample_kw_ops() -> crate::app::screens::kw_ops::KwOpsState { - crate::app::screens::kw_ops::KwOpsState::new( - "title".to_string(), - "mid".to_string(), - "linux".to_string(), - serde_json::from_value(serde_json::json!({ - "path": "/kernel", - "branch": "main" - })) - .unwrap(), - crate::kw::readiness::KwReadiness { - kw_binary: crate::kw::readiness::KwBinaryProbe { - available: true, - version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::readiness::KwVersionCheck::Meets, - }, - tree: crate::kw::readiness::TreeReadiness::Ready { - 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), - boot_once: crate::kw::readiness::BootOnceState::Unknown, - }, - ) - } - #[tokio::test] 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(DeployStartKind::Deploy); 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(); + .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 { @@ -616,16 +646,16 @@ 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(DeployStartKind::BuildThenDeploy); 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(); + .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); } @@ -634,29 +664,49 @@ 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(DeployStartKind::Deploy); 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); + 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] 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(DeployStartKind::Deploy); 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), - app.state.kw.ops.as_ref().unwrap().pending_deploy + Some(DeployStartKind::Deploy), + app.state + .kw + .ops + .as_ref() + .expect("ops is set") + .pending_deploy ); } } diff --git a/src/app/dependencies.rs b/src/app/dependencies.rs index 7402e4cb..b1200e52 100644 --- a/src/app/dependencies.rs +++ b/src/app/dependencies.rs @@ -4,84 +4,120 @@ use crate::{ app::errors::AppError, config::ConfigSnapshot, infrastructure::{env::EnvTrait, shell::ShellTrait}, - kw::readiness::{probe_kw_binary, KwVersionCheck}, + kw::{models::readiness::KwVersionCheck, readiness::ReadinessService}, 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 external binaries before the terminal starts. + /// + /// 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, + 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", + ); + } } + PatchRenderer::Default => {} } - _ => {} - } - let kw = 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); + 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 kw_version_unconfirmed { + 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)] 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::{ @@ -90,36 +126,26 @@ 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().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, - }) - }); - (env, shell) - } - #[test] fn missing_b4_returns_dependencies_error() { 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 = - check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()).unwrap_err(); + let err = DependencyService::check_external_deps( + &env, + &shell, + &ConfigSnapshot::from(&ConfigState::default()), + ) + .expect_err("missing b4 errors"); assert!(matches!(err, AppError::Dependencies(_))); } @@ -129,9 +155,14 @@ mod tests { let (mut env, shell) = happy_env(); env.expect_which() .withf(|name| name == "git") + .times(0) .returning(|_| false); - let result = check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()); + let result = DependencyService::check_external_deps( + &env, + &shell, + &ConfigSnapshot::from(&ConfigState::default()), + ); assert!(result.is_ok()); } @@ -147,9 +178,11 @@ mod tests { let (mut env, shell) = happy_env(); env.expect_which() .withf(|name| name == "bat") + .times(0) .returning(|_| false); - let result = check_external_deps(&env, &shell, &state.to_snapshot()); + let result = + DependencyService::check_external_deps(&env, &shell, &ConfigSnapshot::from(&state)); assert!(result.is_ok()); } @@ -159,11 +192,16 @@ 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 = check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()); + let result = DependencyService::check_external_deps( + &env, + &shell, + &ConfigSnapshot::from(&ConfigState::default()), + ); assert!(result.is_ok()); } @@ -173,15 +211,23 @@ 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, - }) - }); - - let result = check_external_deps(&env, &shell, &ConfigState::default().to_snapshot()); + 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, + &shell, + &ConfigSnapshot::from(&ConfigState::default()), + ); assert!(result.is_ok()); } diff --git a/src/app/flows/bookmarked.rs b/src/app/flows/bookmarked.rs index e804ae5f..b01a72cc 100644 --- a/src/app/flows/bookmarked.rs +++ b/src/app/flows/bookmarked.rs @@ -3,55 +3,54 @@ 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, }; -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 62e3714f..7090f89f 100644 --- a/src/app/flows/details_actions.rs +++ b/src/app/flows/details_actions.rs @@ -5,125 +5,114 @@ 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, }; 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, - }) -} - -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 +134,19 @@ pub fn generate_help_popup() -> AppPopup { .keybind("Ctrl+t", "Show code-review trailers details") .keybind("w", "Kw operations") .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/edit_config.rs b/src/app/flows/edit_config.rs index 2c491d00..717b48d0 100644 --- a/src/app/flows/edit_config.rs +++ b/src/app/flows/edit_config.rs @@ -2,84 +2,85 @@ 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, }; -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,140 +90,157 @@ pub fn generate_help_popup() -> AppPopup { .keybind("k/🡅", "Up") .keybind("←/→", "Cycle the target kernel tree while editing that row") .build() + } } #[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::{bootstrap_parts, ConfigActor, ConfigHandle, 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::*; - - static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + 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::{self, VarError}, + fs, + path::PathBuf, + process, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, + }; + use tokio::sync::mpsc; - 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-edit-config-{prefix}-{}-{n}", - std::process::id() - )); - fs::create_dir_all(&p).unwrap(); - 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") - .returning(|_| Err(VarError::NotPresent.into())); - mock.expect_var() - .withf(move |key| key == "HOME") - .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" - ) - }) - .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) = bootstrap_parts(&env, OsFileSystem).unwrap(); - for key in keys { - state.kernel_trees.insert( - (*key).to_string(), - serde_json::from_value(serde_json::json!({ - "path": format!("/{key}"), - "branch": "master" - })) - .unwrap(), - ); + 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 = state.to_snapshot(); - 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, }, - reviewed_patchsets: HashMap::new(), + user_state: UserLoreState { + bookmarked_patchsets: BookmarkedPatchsetsState { + bookmarked_patchsets: vec![], + patchset_index: 0, + }, + 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() { @@ -230,7 +248,7 @@ mod tests { description, formatted_keybinds, .. - } = generate_help_popup() + } = App::build_edit_config_help_popup() else { panic!("expected help popup"); }; @@ -249,22 +267,22 @@ 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(); + .expect("edit config handles"); } - handle_edit_config(&mut app, InputEvent::EditConfigField) + app.handle_edit_config(InputEvent::EditConfigField) .await - .unwrap(); - handle_edit_config(&mut app, InputEvent::NavigateRight) + .expect("edit config handles"); + app.handle_edit_config(InputEvent::NavigateRight) .await - .unwrap(); - handle_edit_config(&mut app, InputEvent::StageConfigEdit) + .expect("edit config handles"); + app.handle_edit_config(InputEvent::StageConfigEdit) .await - .unwrap(); - handle_edit_config(&mut app, InputEvent::SaveConfig) + .expect("edit config handles"); + app.handle_edit_config(InputEvent::SaveConfig) .await - .unwrap(); + .expect("edit config handles"); assert_eq!( Some("linux"), @@ -276,8 +294,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; } @@ -291,13 +309,13 @@ mod tests { ); app.init_edit_config(); - handle_edit_config(&mut app, InputEvent::SaveConfig) + 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 a597c7f4..b0dcfff3 100644 --- a/src/app/flows/kw_ops.rs +++ b/src/app/flows/kw_ops.rs @@ -1,14 +1,15 @@ -use std::sync::Arc; +use std::{io, path::PathBuf, sync::Arc}; use color_eyre::Result; +use tokio::task; use crate::{ app::{ - popup::AppPopup, - screens::{ + models::{ kw_ops::{DeployStartKind, KwOpsFocus, KwOpsState}, - CurrentScreen, + popup::AppPopup, }, + screens::CurrentScreen, App, }, infrastructure::file_system::FileSystemError, @@ -16,7 +17,7 @@ use crate::{ kw::{ errors::{KwError, KwStartError}, messages::{DeployOptions, StartRequest}, - readiness::BootOnceState, + models::readiness::BootOnceState, status::{KwJobStatus, KwStatusSnapshot}, }, }; @@ -25,587 +26,611 @@ 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) +#[derive(Clone, Copy)] +enum KwStartKind { + Build, + Deploy, + BuildThenDeploy, } -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() +impl KwStartKind { + fn title(self) -> &'static str { + match self { + Self::Build => "Cannot start build", + Self::Deploy => "Cannot start deploy", + Self::BuildThenDeploy => "Cannot start build+deploy", } - 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 action_word(self) -> &'static str { + match self { + Self::Build => "build", + Self::Deploy | Self::BuildThenDeploy => "job", + } } -} -/// 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; + fn pending_kind(self) -> Option { + match self { + Self::Build => None, + Self::Deploy => Some(DeployStartKind::Deploy), + Self::BuildThenDeploy => Some(DeployStartKind::BuildThenDeploy), } } - app.state.kw.status = Some(snapshot); } -fn snapshot_job_is_terminal(job: &KwJobStatus) -> bool { - matches!( - job, - KwJobStatus::Succeeded { .. } | KwJobStatus::Failed { .. } | KwJobStatus::Cancelled { .. } - ) -} +impl App { + /// 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; + } + let Some(path) = self + .state + .kw + .status + .as_ref() + .and_then(|status| status.job.log_path()) + .map(PathBuf::from) + else { + return false; + }; + let fs = Arc::clone(&self.services.fs); + let result = + 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})"), + }; + let Some(ops) = self.state.kw.ops.as_mut() else { + return false; + }; + Self::assign_log_tail(ops, text) + } -/// 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; + /// 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() { + 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; + } + } + self.state.kw.status = Some(snapshot); } -} -/// 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 -} + /// 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; + } + } -/// 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, + /// 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 } -} -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(); + /// Seed the projection from GetStatus after the watch is gone. + /// + /// 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; + return; + }; + match kw.get_status().await { + Ok(snapshot) => self.apply_kw_snapshot_refreshing_readiness(snapshot).await, + Err(_) => self.state.kw.status = None, + } + } + + 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::TextInput(ch) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.append_edit(ch); + InputEvent::Backspace => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.backspace_edit(); + } } - } - 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(); + InputEvent::TextInput(ch) => { + if let Some(ops) = self.state.kw.ops.as_mut() { + ops.append_edit(ch); + } } - if focus == Some(KwOpsFocus::Branch) { - refresh_kw_ops_readiness(app).await; + 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(()); } - 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(); + match input { + InputEvent::OpenHelp => { + self.state.popup = Some(Self::build_kw_ops_help_popup()); } - } - InputEvent::NavigateUp => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.highlight_prev(); + InputEvent::Back => { + self.set_current_screen(CurrentScreen::PatchsetDetails); } - } - InputEvent::EditKwOpsField => { - if !job_is_busy(app) { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.begin_edit(); + 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?, + _ => {} } - 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(()) } - 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(()); - }; + /// 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(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); + 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, + )); } - } else { - app.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(), )); } - 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(()) } - Ok(()) -} -#[derive(Clone, Copy)] -enum KwStartKind { - Build, - Deploy, - BuildThenDeploy, -} - -impl KwStartKind { - fn title(self) -> &'static str { - match self { - Self::Build => "Cannot start build", - Self::Deploy => "Cannot start deploy", - Self::BuildThenDeploy => "Cannot start build+deploy", + /// 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, } } - fn action_word(self) -> &'static str { - match self { - Self::Build => "build", - Self::Deploy | Self::BuildThenDeploy => "job", + /// 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; } } - fn pending_kind(self) -> Option { - match self { - Self::Build => None, - Self::Deploy => Some(DeployStartKind::Deploy), - Self::BuildThenDeploy => Some(DeployStartKind::BuildThenDeploy), - } + 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() } } -async fn start_job(app: &mut App, kind: KwStartKind) -> Result<()> { - if job_is_busy(app) { - return Ok(()); +impl App { + fn map_tail_read(result: Result) -> String { + match result { + Ok(text) => text, + Err(FileSystemError::IoError(error)) => { + if error.kind() == io::ErrorKind::NotFound { + String::new() + } else { + format!("(could not read log: {error})") + } + } + } } - 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() - ) + + fn assign_log_tail(ops: &mut KwOpsState, text: String) -> bool { + if ops.log_tail == text { + false } else { - format!("Set a branch before starting a {}.", kind.action_word()) - }; - app.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 { - app.state.popup = Some(AppPopup::info(kind.title(), reason.to_string())); - return Ok(()); + ops.log_tail = text; + true } } - 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 is_snapshot_job_terminal(job: &KwJobStatus) -> bool { + match job { + KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } => true, + KwJobStatus::Idle | KwJobStatus::Running { .. } => false, } - 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); - } - } - 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()); + async fn start_job(&mut self, kind: KwStartKind) -> Result<()> { + if self.is_job_busy() { + return Ok(()); } - Err(error) => { - app.state.popup = Some(AppPopup::info(kind.title(), error.to_string())); + 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(()); } - } - Ok(()) -} - -fn needs_boot_once_confirm(ops: &KwOpsState) -> bool { - matches!( - ops.readiness.boot_once, - BootOnceState::On | BootOnceState::Unknown - ) && !ops.boot_once_acknowledged -} -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()) + // 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 { + 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 => {} } - }; - 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; + 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 refresh kw status", - 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(()) } -} -async fn cancel_job(app: &mut App) -> Result<()> { - if !job_is_running(app) { - return Ok(()); + fn needs_boot_once_confirm(ops: &KwOpsState) -> bool { + match ops.readiness.boot_once { + BootOnceState::Off => false, + BootOnceState::On | BootOnceState::Unknown => !ops.boot_once_acknowledged, + } } - 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 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()) } - } - Err(KwError::NoJobRunning) => { - if let Some(ops) = app.state.kw.ops.as_mut() { - ops.cancel_requested = false; + }; + + 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 cancel job", error.to_string())); } } - Ok(()) -} -async fn restore_branch(app: &mut App) -> Result<()> { - if job_is_running(app) { - return Ok(()); - } - 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 cancel_job(&mut self) -> Result<()> { + if !self.is_job_running() { + return Ok(()); } - Err(error) => { - app.state.popup = Some(AppPopup::info("Cannot restore branch", error.to_string())); + 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(()) } - Ok(()) -} -fn job_is_running(app: &App) -> bool { - matches!( - app.state.kw.status.as_ref().map(|status| &status.job), - Some(KwJobStatus::Running { .. }) - ) -} - -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) -} - -/// 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, + .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(()) } -} -/// 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_running(&self) -> bool { + 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, + } } -} -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() + fn is_job_busy(&self) -> bool { + self.is_job_running() + || self + .state + .kw + .ops + .as_ref() + .is_some_and(|ops| ops.start_requested) + } } #[cfg(test)] mod tests { - use super::*; - use crate::app::screens::kw_ops::KwOpsState; - use crate::kw::readiness::{ - BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, - TreeReadiness, - }; - use crate::kw::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" - })) - .unwrap(), - 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, - 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), - boot_once: BootOnceState::Unknown, - }, - ) + ) + } } + use super::*; + use helpers::*; #[test] 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))); + 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 text = tail_text_from_read(Err(error)); + 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")); } @@ -613,9 +638,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); } @@ -625,7 +653,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 39535857..bd382dd8 100644 --- a/src/app/flows/latest.rs +++ b/src/app/flows/latest.rs @@ -3,87 +3,96 @@ 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, }; -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 82ac0111..5e7af5e0 100644 --- a/src/app/flows/mail_list.rs +++ b/src/app/flows/mail_list.rs @@ -4,99 +4,106 @@ 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, }; -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 34312548..6edd6a9d 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 { @@ -8,27 +8,30 @@ 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 { +impl App { + 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 +44,7 @@ fn resolve_open_patchset_result( body: format!("The selected patchset couldn't be opened.\nReason: {error:#}"), }, } + } } #[cfg(test)] @@ -51,15 +55,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 +81,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/input.rs b/src/app/input.rs index 3bd425dc..99516540 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/actor_lifecycle.rs b/src/app/integration_tests/actor_lifecycle.rs index acbdc7d5..ee740513 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(); @@ -45,7 +53,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 +63,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 a41ac61f..22a256af 100644 --- a/src/app/integration_tests/flow_errors.rs +++ b/src/app/integration_tests/flow_errors.rs @@ -1,16 +1,6 @@ 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, - }, - input::event::InputEvent, -}; +use crate::{app::screens::CurrentScreen, input::event::InputEvent}; use super::helpers::{ app_harness::AppHarness, @@ -27,22 +17,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 + .expect("mailing list selection handles"); 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 + .expect("latest patchsets handles"); assert_eq!( CurrentScreen::LatestPatchsets, @@ -67,22 +53,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 + .expect("mailing list selection handles"); 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 + .expect("bookmarked patchsets handles"); assert_eq!( CurrentScreen::BookmarkedPatchsets, @@ -98,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/flow_navigation.rs b/src/app/integration_tests/flow_navigation.rs index fb094362..e9b8aa2e 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,35 +17,33 @@ 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 + .expect("mailing list selection handles"); 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 + .expect("latest patchsets handles"); 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(); + .expect("patchset details handles"); assert_eq!( CurrentScreen::LatestPatchsets, @@ -71,35 +60,33 @@ 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 + .expect("mailing list selection handles"); 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 + .expect("bookmarked patchsets handles"); 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(); + .expect("patchset details handles"); assert_eq!( CurrentScreen::BookmarkedPatchsets, diff --git a/src/app/integration_tests/helpers/app_harness.rs b/src/app/integration_tests/helpers/app_harness.rs index dcf38dd2..e168f6e5 100644 --- a/src/app/integration_tests/helpers/app_harness.rs +++ b/src/app/integration_tests/helpers/app_harness.rs @@ -4,10 +4,10 @@ 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::{cache::BootstrapLoreData, handle::LoreApiHandle}, + lore::application::{handle::LoreApiHandle, models::cache::BootstrapLoreData}, render::handle::RenderHandle, terminal::{handle::TerminalHandle, messages::TerminalMessage}, }; @@ -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 0195f4eb..3a8344bb 100644 --- a/src/app/integration_tests/kw_ops.rs +++ b/src/app/integration_tests/kw_ops.rs @@ -1,16 +1,24 @@ +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::{ - app::{ - flows::details_actions::handle_patchset_details, - popup::AppPopup, - screens::{details_actions::PatchsetDetailsState, CurrentScreen}, + infrastructure::{ + file_system::{MockFileSystemTrait, OsFileSystem}, + process::FakeProcess, }, input::event::InputEvent, -}; - -use super::helpers::{ - app_harness::{dummy_terminal_handle, AppHarness}, - lore::{sample_patch, sample_patchset_details}, - render::sample_rendered_preview, + kw::{ + history::MockKwHistoryStore, + models::readiness::{BootOnceState, DeployAloneRefusal}, + status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, + }, }; #[tokio::test] @@ -19,13 +27,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 + .expect("patchset details handles"); assert_eq!( CurrentScreen::PatchsetDetails, @@ -37,32 +43,16 @@ async fn open_kw_ops_without_actor_stays_on_details() { "not attached", ); } - -fn details_state() -> PatchsetDetailsState { - PatchsetDetailsState::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"); +mod helpers { + use super::super::helpers::{ + lore::{sample_patch, sample_patchset_details}, + render::sample_rendered_preview, }; - assert_eq!(expected_title, title); - assert!( - body.contains(fragment), - "expected popup body to contain {fragment:?}, got {body:?}" - ); -} - -#[cfg(unix)] -mod unix { + use crate::app::screens::details_actions::PatchsetDetailsState; use std::{ - path::PathBuf, + env, fs, io, + path::{Path, PathBuf}, + process, sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, @@ -70,672 +60,60 @@ mod unix { time::Duration, }; + use tokio::{sync::mpsc, time}; + + use crate::app::handle::AppHandle; 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, - 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::{KwBuildRecord, MockKwHistoryStore}, - readiness::{BootOnceState, DeployAloneRefusal}, - status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, - }, - lore::application::cache::BootstrapLoreData, + kw::{actor::KwActor, history::MockKwHistoryStore, models::history::KwBuildRecord}, + lore::application::models::cache::BootstrapLoreData, terminal::{ actor::TerminalActor, messages::TerminalFrame, session::MockTerminalSessionApi, }, ui::{ actor::UiActor, - scene::{UiBody, UiScene}, + scene::{KwOpsScene, PopupScene, UiBody, UiScene}, }, }; - 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}, }, - popup::AppPopup, - screens::kw_ops::DeployStartKind, + 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")); - - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - 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; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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("")); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - assert!(app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.branch.is_empty())); - - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); - assert_info_popup( - app.state.popup.as_ref(), - "Cannot start build", - "detached or unverifiable", - ); - - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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()), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); - 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(); - app.state.kw.status = Some(status); - assert!(matches!( - app.state.kw.status.as_ref().map(|s| &s.job), - Some(KwJobStatus::Running { .. }) - )); - - handle_kw_ops(&mut app, InputEvent::CancelKwJob) - .await - .unwrap(); - assert!(app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.cancel_requested)); - - process.last_child().finish(0); - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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()), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); - 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)); - - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); - assert!(app.state.popup.is_none()); - assert_eq!(1, process.spawned().len()); - - process.last_child().finish(0); - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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()), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - handle_kw_ops(&mut app, InputEvent::StartKwBuild) - .await - .unwrap(); - app.state.kw.status = None; - - fallback_kw_status(&mut app).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; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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() - .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), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - 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, - }); - - refresh_kw_ops_log_tail(&mut app).await; - assert!(app - .state - .kw - .ops - .as_ref() - .is_some_and(|ops| ops.log_tail.contains("from mock"))); - - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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")); - handle_patchset_details(&mut app, 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()) - .await - .unwrap(); - - assert_eq!("--verbose", app.state.kw.ops.as_ref().unwrap().extra_args); - assert_eq!(CurrentScreen::KwOps, app.state.navigation.current_screen); - - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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")); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - handle_kw_ops(&mut app, InputEvent::Back).await.unwrap(); - assert_eq!( - CurrentScreen::PatchsetDetails, - app.state.navigation.current_screen - ); - assert!(app.state.lore.details.is_some()); - - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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(), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - let ops = app.state.kw.ops.as_ref().expect("KwOps state"); - assert_eq!( - "root@box:22", - ops.readiness.deploy_remote.as_ref().unwrap().endpoint() - ); - assert_eq!(BootOnceState::Off, ops.readiness.boot_once); - - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); + 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())), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - handle_kw_ops(&mut app, InputEvent::StartKwDeploy) - .await - .unwrap(); - - 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().unwrap().pending_deploy - ); - assert!(process.spawned().is_empty()); - - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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(), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - handle_kw_ops(&mut app, InputEvent::StartKwDeploy) - .await - .unwrap(); - assert_info_popup( - app.state.popup.as_ref(), - "Cannot start deploy", - "no build recorded", + assert_eq!(expected_title, title); + assert!( + body.contains(fragment), + "expected popup body to contain {fragment:?}, got {body:?}" ); - assert!(process.spawned().is_empty()); - - shutdown_kw(&app).await; - std::fs::remove_dir_all(&log_dir).unwrap(); } - - #[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())), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - handle_kw_ops(&mut app, InputEvent::StartKwDeploy) - .await - .unwrap(); - 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; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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(), - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - - handle_kw_ops(&mut app, InputEvent::StartKwBuildThenDeploy) - .await - .unwrap(); - assert!(app.state.popup.is_none()); - assert_eq!(vec!["build"], process.spawned()[0].args); - - process.last_child().finish(0); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if process.spawned().len() >= 2 { - return; - } - tokio::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; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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() - .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)) - } - }); - 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, - ); - handle_patchset_details(&mut app, InputEvent::OpenKwOps, &dummy_terminal_handle()) - .await - .unwrap(); - assert!(matches!( - app.state.kw.ops.as_ref().unwrap().readiness.deploy_alone, - Err(DeployAloneRefusal::NoBuildRecord) - )); - - handle_kw_ops(&mut app, 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(); - - let ops = app.state.kw.ops.as_ref().unwrap(); - 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; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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().unwrap().clone(); - let (scenes, event_tx, handle) = spawn_app_actor(app); - - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); - wait_for_kw_ops(&scenes, |_| true).await; - event_tx.send(InputEvent::StartKwDeploy).await.unwrap(); - 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(); - wait_for_latest_popup(&scenes, |popup| popup.is_none()).await; - assert!(process.spawned().is_empty()); - - drop(event_tx); - handle.run_until_done().await.unwrap(); - kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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().unwrap().clone(); - let (scenes, event_tx, handle) = spawn_app_actor(app); - - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); - wait_for_kw_ops(&scenes, |_| true).await; - event_tx.send(InputEvent::StartKwDeploy).await.unwrap(); - wait_for_latest_popup(&scenes, |popup| popup.is_some()).await; - - event_tx.send(InputEvent::NavigateRight).await.unwrap(); - event_tx.send(InputEvent::ConfirmPopup).await.unwrap(); - 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.unwrap(); - kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - #[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().unwrap().clone(); - let (scenes, event_tx, handle) = spawn_app_actor(app); - - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); - wait_for_kw_ops(&scenes, |_| true).await; - - event_tx.send(InputEvent::StartKwBuild).await.unwrap(); - 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.unwrap(); - wait_for_details(&scenes).await; - 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; - process.last_child().write_log(b"ld: linking vmlinux\n"); - tokio::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.unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if scene_count(&scenes) > draws_on_details { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("input must still redraw off KwOps"); - - event_tx.send(InputEvent::OpenKwOps).await.unwrap(); - 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); - tokio::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.unwrap(); - kw.shutdown().await; - std::fs::remove_dir_all(&log_dir).unwrap(); - } - - fn app_with_details_and_kw(log_dir: &std::path::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, @@ -744,8 +122,8 @@ mod unix { ) } - fn app_with_details_and_kw_process( - log_dir: &std::path::Path, + pub fn app_with_details_and_kw_process( + log_dir: &Path, kw_shell: MockShellTrait, process: Arc, app_fs: Arc, @@ -760,8 +138,8 @@ mod unix { ) } - fn app_with_kw( - log_dir: &std::path::Path, + pub fn app_with_kw( + log_dir: &Path, kw_shell: MockShellTrait, process: Arc, app_fs: Arc, @@ -797,39 +175,57 @@ 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() + .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 } - 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() + .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 } - 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(), @@ -845,139 +241,252 @@ 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 { "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(std::io::Error::new( - std::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(std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing", - ))) - } - }); - fs.expect_metadata().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::other( - "no metadata", - ))) - }); - fs.expect_create_dir_all().returning(|_| Ok(())); + 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() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(0..=2) + .returning(|_| Ok(())); 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.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 { + pub 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(std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::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 { + pub 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 } - 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() + pub fn apply_config() -> ConfigSnapshot { + 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) { + 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 = 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).expect("dir creates"); dir } - fn spawn_app_actor( + pub fn spawn_app_actor( 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); @@ -988,101 +497,104 @@ 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(()) }); 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 { + pub async fn wait_for_nav(scenes: &Arc>>, predicate: impl Fn(&str) -> bool) { + time::timeout(Duration::from_secs(5), async { loop { if scenes .lock() - .unwrap() + .expect("scenes locks") .iter() .any(|scene| predicate(&nav_text(scene))) { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await .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(&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( + let matches = scenes.lock().expect("scenes locks").last().is_some_and( |scene| matches!(&scene.body, UiBody::KwOps(ops) if predicate(ops)), ); if matches { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await .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<&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() - .unwrap() + .expect("scenes locks") .last() .is_some_and(|scene| predicate(scene.popup.as_ref())); if matches { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await .expect("expected popup state did not appear"); } - async fn wait_for_details(scenes: &Arc>>) { - tokio::time::timeout(Duration::from_secs(5), async { + pub async fn wait_for_details(scenes: &Arc>>) { + time::timeout(Duration::from_secs(5), async { loop { if scenes .lock() - .unwrap() + .expect("scenes locks") .last() .is_some_and(|scene| matches!(scene.body, UiBody::PatchsetDetails(_))) { return; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await .expect("expected Patchset Details scene did not appear"); } - fn scene_count(scenes: &Arc>>) -> usize { - scenes.lock().unwrap().len() + 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 @@ -1091,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"); +} diff --git a/src/app/integration_tests/kw_status.rs b/src/app/integration_tests/kw_status.rs index cd974bf5..c4334a97 100644 --- a/src/app/integration_tests/kw_status.rs +++ b/src/app/integration_tests/kw_status.rs @@ -1,52 +1,350 @@ -use std::{ - path::PathBuf, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, Mutex, - }, - time::Duration, -}; - -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::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}, -}; - -const KERNEL_TREE_PATH: &str = "/kernel"; -const BUILD_BRANCH: &str = "patchset-2026-08-20-15-00-00"; - -static LOG_DIR_SEQ: AtomicU64 = AtomicU64::new(0); +use std::{fs, time::Duration}; + +use tokio::time; + +use crate::input::event::InputEvent; +use crate::ui::scene::PopupBody; + +mod helpers { + use super::super::helpers::{ + app_harness::{dummy_config_handle, dummy_render_handle}, + lore::{lore_handle_with_persistence, sample_mailing_list}, + }; + use crate::kw::handle::KwHandle; + use crate::{ + app::{actor::AppActor, handle::AppHandle, App}, + 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::{PopupScene, UiScene}, + }, + }; + use std::{ + env, fs, io, + path::{Path, PathBuf}, + process, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, + }; + use tokio::{sync::mpsc, 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"); + } + + 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"); + } + + 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() { 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; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -58,7 +356,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}")) }) @@ -72,9 +370,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; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test(flavor = "multi_thread")] @@ -87,30 +385,33 @@ 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), "a closed kw watch must not spin the render loop" ); - event_tx.send(InputEvent::NavigateDown).await.unwrap(); - tokio::time::timeout(Duration::from_secs(5), async { + 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 { break; } - tokio::time::sleep(Duration::from_millis(10)).await; + time::sleep(Duration::from_millis(10)).await; } }) .await .expect("input must still redraw after the kw watch closes"); drop(event_tx); - handle.run_until_done().await.unwrap(); - std::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")] @@ -120,34 +421,39 @@ 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(); - tokio::time::timeout(Duration::from_secs(5), handle.run_until_done()) + 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; - std::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 { - crate::ui::scene::PopupBody::Confirm { + PopupBody::Confirm { options, selected, .. } => { assert_eq!( @@ -159,273 +465,81 @@ 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; - std::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; - std::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(); - tokio::time::timeout(Duration::from_secs(5), async { + handle.run_until_done().await.expect("actor finishes"); + 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(); -} - -fn spawn_app_actor( - app: crate::app::App, -) -> ( - Arc>>, - tokio::sync::mpsc::Sender, - crate::app::handle::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().unwrap().push(*scene); - } - Ok(()) - }); - - 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 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, -) { - let process = Arc::new(FakeProcess::new()); - let mut history = MockKwHistoryStore::new(); - history - .expect_apply_record_for_branch() - .returning(|_, _| Ok(None)); - history.expect_record_build().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 = crate::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) { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if scenes - .lock() - .unwrap() - .iter() - .any(|scene| predicate(&nav_text(scene))) - { - return; - } - tokio::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<&crate::ui::scene::PopupScene>) -> bool, -) { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let matches = scenes - .lock() - .unwrap() - .last() - .is_some_and(|scene| predicate(scene.popup.as_ref())); - if matches { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("expected popup state did not appear"); -} - -fn scene_count(scenes: &Arc>>) -> usize { - scenes.lock().unwrap().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 = std::env::temp_dir().join(format!( - "patch-hub-app-kw-status-{}-{}-{n}", - test_name, - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - 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 { - 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() -} - -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 -} - -fn kw_actor_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(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_create_dir_all().returning(|_| Ok(())); - fs -} - -fn kw_actor_env() -> MockEnvTrait { - let mut env = MockEnvTrait::new(); - env.expect_which().returning(|_| true); - env + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } diff --git a/src/app/integration_tests/mod.rs b/src/app/integration_tests/mod.rs index 6f65a510..99d4d676 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; @@ -43,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 881d1b89..7382a277 100644 --- a/src/app/integration_tests/patchset_actions.rs +++ b/src/app/integration_tests/patchset_actions.rs @@ -1,48 +1,579 @@ use std::{ - collections::{HashMap, HashSet, VecDeque}, - path::PathBuf, + collections::HashSet, + fs, io, sync::{Arc, Mutex}, + time::Duration, }; -use tokio::{spawn, sync::mpsc}; +use tokio::time; +use super::helpers::lore::lore_handle_with_persistence; use crate::{ - app::{ - 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::{ - cache::BootstrapLoreData, handle::LoreApiHandle, messages::LoreApiMessage, - }, + 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, -}; +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::{ + collections::{HashMap, HashSet, VecDeque}, + env, fs, io, + path::PathBuf, + process, + 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(), + ) + } -const KERNEL_TREE_PATH: &str = "/kernel"; -const BASE_BRANCH: &str = "main"; + /// 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 + } -type ReviewedState = HashMap>; -type SharedReviewedState = Arc>>; + 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 + } + + 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) + } + + 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() { @@ -56,7 +587,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( @@ -92,7 +625,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( @@ -101,7 +636,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] @@ -125,7 +660,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( @@ -134,7 +671,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] @@ -179,7 +716,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(), @@ -202,8 +741,11 @@ 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(std::io::Error::other("disk full")))); + .returning(|_| Err(FileSystemError::IoError(io::Error::other("disk full")))); let mut app = app_with_details( clean_fs(), shell, @@ -213,7 +755,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( @@ -243,7 +787,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, @@ -253,7 +797,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; @@ -278,10 +824,14 @@ 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"); - kw.start_build(kw_start_request()).await.unwrap(); + let kw = app.services.kw.as_ref().expect("kw handle is available"); + 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( @@ -289,10 +839,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; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -308,11 +858,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, @@ -327,23 +889,32 @@ 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(); - kw.start_build(kw_start_request()).await.unwrap(); + let kw = app + .services + .kw + .as_ref() + .expect("kw handle is available") + .clone(); + 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(); - tokio::time::timeout(std::time::Duration::from_secs(10), async { + 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 @@ -356,7 +927,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( @@ -364,10 +937,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; - std::fs::remove_dir_all(&log_dir).unwrap(); + fs::remove_dir_all(&log_dir).expect("temp dir removes"); } #[tokio::test] @@ -382,12 +955,15 @@ 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); 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!( @@ -396,7 +972,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]); @@ -415,395 +991,23 @@ 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); 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()); 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. -#[allow(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::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::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().unwrap() = 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().returning(|_| Ok(())); - store -} - -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() -} - -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() -} - -fn clean_fs() -> MockFileSystemTrait { - let mut fs = MockFileSystemTrait::new(); - fs.expect_is_dir() - .returning(|path| matches!(path.to_str(), Some("/kernel") | Some("/kernel/.git"))); - fs.expect_is_file().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().returning(move |cmd| { - calls_for_execute.lock().unwrap().push(command_parts(cmd)); - Ok(outputs_for_execute - .lock() - .unwrap() - .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().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_file() - .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, - "missing", - ))) - }); - fs.expect_read_dir().returning(|_| { - Err(FileSystemError::IoError(std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing", - ))) - }); - fs.expect_create_dir_all().returning(|_| Ok(())); - fs -} - -fn kw_actor_env() -> MockEnvTrait { - let mut env = MockEnvTrait::new(); - env.expect_which().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 = std::env::temp_dir().join(format!( - "patch-hub-app-kw-logs-{}-{}", - test_name, - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - 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/loading.rs b/src/app/loading.rs index d1fdc329..471ee813 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::{ @@ -109,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/mod.rs b/src/app/mod.rs index 0aa9eee7..4da95d2c 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`](crate::app::actor::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`](crate::lore::application::handle::LoreApiHandle), -//! [`RenderHandle`](crate::render::handle::RenderHandle), plus injected -//! infrastructure traits). Screen-specific input is dispatched from -//! [`AppActor`](crate::app::actor::AppActor) into [`crate::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; @@ -17,6 +11,7 @@ pub(crate) mod flows; pub mod handle; pub mod input; pub(crate) mod loading; +pub(crate) mod models; pub mod popup; pub mod screens; pub mod state; @@ -32,7 +27,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}; @@ -42,26 +37,26 @@ 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, }, 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::{popup::AppPopup, view_model::AppViewModel}; use screens::{ bookmarked::BookmarkedPatchsetsState, details_actions::{PatchsetAction, PatchsetDetailsState}, @@ -71,7 +66,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 { @@ -80,11 +74,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, } @@ -101,14 +94,9 @@ 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, config_handle: ConfigHandle, @@ -296,7 +284,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, @@ -326,6 +314,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 = ConfigUpdateDraft::from(edit_config); + 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 { + AppViewModel::from(&self.state) + } +} + +impl App { async fn sync_patchset_bookmark(&mut self) -> Result<()> { let details = self .state @@ -373,7 +401,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 @@ -382,7 +410,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(), @@ -397,7 +425,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 @@ -427,7 +455,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, @@ -451,23 +479,27 @@ 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( + Ok(snapshot) => match snapshot.job { + 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.", - )) - } - _ => None, + )), + KwJobStatus::Idle + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } => None, + }, + Err(_) => None, } } /// 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); + 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, &*self.services.shell, @@ -475,7 +507,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. @@ -491,7 +527,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 @@ -514,104 +550,69 @@ impl App { } } }; - popup::AppPopup::info("Patchset Apply Success", popup_body) + AppPopup::info("Patchset Apply Success", popup_body) } - Err(msg) => popup::AppPopup::info("Patchset Apply Fail", msg), + Err(msg) => AppPopup::info("Patchset Apply Fail", msg), } } - /// 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; + fn is_patchset_action_selected( + details: &PatchsetDetailsState, + action: &PatchsetAction, + ) -> bool { + matches!(details.patchset_actions.get(action), Some(true)) } - /// 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"); + fn build_reviewed_reply_request( + details: &PatchsetDetailsState, + successful_indexes: 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, } - 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 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_apply_patchset_request(details: &PatchsetDetailsState) -> ApplyPatchsetRequest { + ApplyPatchsetRequest { + patch_title: details.representative_patch.title().clone(), + patchset_path: details.patchset_path.clone(), + } } -} -fn apply_patchset_request(details: &PatchsetDetailsState) -> ApplyPatchsetRequest { - ApplyPatchsetRequest { - patch_title: details.representative_patch.title().clone(), - patchset_path: details.patchset_path.clone(), + 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), + }) } } -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), - }) -} - #[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 @@ -624,40 +625,48 @@ 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() { 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 )); @@ -666,13 +675,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(), @@ -690,7 +702,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); diff --git a/src/app/models/kw_ops.rs b/src/app/models/kw_ops.rs new file mode 100644 index 00000000..4389826a --- /dev/null +++ b/src/app/models/kw_ops.rs @@ -0,0 +1,47 @@ +use crate::{config::KernelTree, kw::models::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 [`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 new file mode 100644 index 00000000..38b9928b --- /dev/null +++ b/src/app/models/mod.rs @@ -0,0 +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 00000000..8f579170 --- /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/models/view_model.rs b/src/app/models/view_model.rs new file mode 100644 index 00000000..f573946d --- /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 from `AppState`; 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/popup.rs b/src/app/popup.rs index 1eb7c3fc..bc95249d 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), - }, -} +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/details_actions.rs b/src/app/screens/details_actions.rs index d07f1d28..a69fe77c 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,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, @@ -123,7 +119,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 +133,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); } @@ -219,17 +217,23 @@ 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}; - 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 @@ -242,39 +246,46 @@ 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() { 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/edit_config.rs b/src/app/screens/edit_config.rs index d4ac2832..95cc68be 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() @@ -197,40 +201,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(), @@ -309,36 +315,42 @@ 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" - })) - .unwrap(), - ); + 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); - state.to_snapshot() - } - 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() { - 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 +444,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 +454,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 +469,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 +487,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/screens/kw_ops.rs b/src/app/screens/kw_ops.rs index 2a7069d3..16464f02 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}; +use crate::{config::KernelTree, kw::models::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, -} +use crate::app::models::kw_ops::{KwOpsFocus, KwOpsState}; impl KwOpsState { pub fn new( @@ -76,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; @@ -92,7 +46,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 @@ -103,7 +57,7 @@ impl KwOpsState { } else { self.extra_args.as_str() }; - split_extra_args(raw) + Self::split_extra_args(raw) } pub fn highlight_prev(&mut self) { @@ -145,47 +99,53 @@ impl KwOpsState { } } -fn split_extra_args(raw: &str) -> Vec { - raw.split_whitespace().map(str::to_string).collect() +impl KwOpsState { + fn split_extra_args(raw: &str) -> Vec { + raw.split_whitespace().map(str::to_string).collect() + } } #[cfg(test)] mod tests { - use super::*; - use crate::kw::readiness::{ - BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwVersionCheck, - TreeReadiness, - }; - use crate::kw::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, - 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), - 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 0d59b242..0470e387 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, }; @@ -110,74 +110,79 @@ impl LatestPatchsetsState { #[cfg(test)] mod tests { - use super::*; - use std::sync::Arc; - - use crate::{ - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - lore::{ - application::{actor::LoreApiActor, 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" - })) - .unwrap() - } + }; + 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() { 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 +199,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 +218,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/app/screens/mail_list.rs b/src/app/screens/mail_list.rs index 8911930b..63065979 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, }; @@ -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/app/screens/mod.rs b/src/app/screens/mod.rs index 4659f6f1..e476cf07 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/app/state.rs b/src/app/state.rs index 56b84203..d8b6d6c5 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, }, }, @@ -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/app/view_model.rs b/src/app/view_model.rs index eb2a37a1..7ca7861a 100644 --- a/src/app/view_model.rs +++ b/src/app/view_model.rs @@ -1,848 +1,788 @@ -//! Owned, typed presentation projections. +//! Owned presentation projections built from `AppState` by `App::present`. //! -//! [`AppViewModel`] is built from [`super::state::AppState`] by -//! [`project_state`], which is called via [`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 -//! `UiScene` nodes. +//! They are what `App` knows about presentation before `UiCore` turns them +//! into paint-ready `UiScene` nodes. use ansi_to_tui::IntoText; -use ratatui::text::Text; 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}, - 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, + models::{ + readiness::{BootOnceState, DeployAloneRefusal, KwBinaryProbe, TreeReadiness}, + remote::{KwRemote, RemoteRefusal}, }, - /// 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), -} + status::{KwJobStatus, KwPhase, KwStatusSnapshot}, +}; -/// 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, -} +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`]. +/// 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)) +/// [`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 = 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 = 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 + .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() - } 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() - } else { - "(no log yet)".to_string() - } - } 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() + 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 { - format_job_status(job, ops.cancel_requested) - }, - command: format!( - "kw {}", - argv::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() { + "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() { + (None, None) + } else { + (Some(warnings.join(" | ")), None) + } + } + Some(KwJobStatus::Failed { first_error, .. }) => (None, first_error.clone()), Some( - "HEAD is detached or unverifiable; type a branch before starting a job." - .to_string(), + KwJobStatus::Idle | KwJobStatus::Running { .. } | KwJobStatus::Cancelled { .. }, ) + | None => (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 { - None - }, - warnings, - first_error, - log_path, - log_tail, - } -} + ops.log_tail.clone() + }; -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 + 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, + } } } -fn action_label(blocked: Option, key: char) -> String { - blocked.unwrap_or_else(|| format!("available ({key})")) -} +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 + } + } -fn format_deploy_remote(remote: &Result) -> String { - match remote { - Ok(remote) => remote.endpoint(), - Err(reason) => reason.to_string(), + fn format_action_label(blocked: Option, key: char) -> String { + blocked.unwrap_or_else(|| format!("available ({key})")) } -} -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_remote(remote: &Result) -> String { + match remote { + Ok(remote) => remote.endpoint(), + Err(reason) => reason.to_string(), + } } -} -fn format_deploy_command( - remote: &Result, - reboot: bool, - force: bool, - extra_args: &[String], -) -> String { - match remote { - Ok(remote) => format!( - "kw {}", - argv::deploy_argv(&remote.endpoint(), reboot, force, extra_args).join(" ") - ), - Err(_) => "(no remote)".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 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 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(), + } } -} -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_remote_refusal(reason: &RemoteRefusal) -> &'static str { + match reason { + RemoteRefusal::NoRemotesConfigured => "no remotes configured", + RemoteRefusal::NoDefault { .. } => "no default remote", + RemoteRefusal::DefaultNotFound { .. } => "default remote missing", + } } -} -fn format_kw_binary(probe: &crate::kw::readiness::KwBinaryProbe) -> String { - if !probe.available { - return "not on PATH".to_string(); + 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", + } } - match &probe.version_line { - Some(line) => line.clone(), - None => "available (version unknown)".to_string(), + + fn format_kw_binary(probe: &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 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, + }, + } } } #[cfg(test)] mod tests { - use std::{collections::HashMap, path::PathBuf}; - use crate::{ - app::{ - screens::{bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState}, - state::{ - AppState, ConfigUiState, KwUiState, LoreUiState, NavigationState, UserLoreState, + mod helpers { + use super::super::*; + use crate::{ + app::{ + models::kw_ops::KwOpsState, + screens::{ + bookmarked::BookmarkedPatchsetsState, mail_list::MailingListSelectionState, + }, + state::{ + AppState, ConfigUiState, KwUiState, LoreUiState, NavigationState, UserLoreState, + }, }, - }, - config::ConfigState, - kw::status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, - lore::domain::mailing_list::MailingList, - }; + config::{ConfigSnapshot, ConfigState}, + kw::{ + models::readiness::{KwReadiness, KwVersionCheck}, + status::{KwJobKind, KwJobStatus, KwStatusSnapshot}, + }, + lore::domain::mailing_list::MailingList, + }; + use std::{collections::HashMap, path::PathBuf}; - use super::*; + 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 }, + } + } - 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, + 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, }, - latest_patchsets: None, - details: None, - }, - user_state: UserLoreState { - bookmarked_patchsets: BookmarkedPatchsetsState { - bookmarked_patchsets: vec![], - patchset_index: 0, + ) + } + + 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(), }, - reviewed_patchsets: HashMap::new(), - }, - config_state: ConfigUiState { edit_config: None }, - config: ConfigState::default().to_snapshot(), - popup: None, - kw: KwUiState { status, ops: None }, + 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::{ + config::{ConfigSnapshot, ConfigState}, + kw::status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, + }; + + use super::*; #[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, @@ -860,7 +800,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 { @@ -887,17 +827,17 @@ mod tests { "path": "/linux", "branch": "master" })) - .unwrap(), + .expect("json parses"), ); - state.config = config.to_snapshot(); - let mut edit = crate::app::screens::edit_config::EditConfigState::new(&state.config); + state.config = ConfigSnapshot::from(&config); + let mut edit = EditConfigState::new(&state.config); while edit.highlighted() != 11 { edit.highlight_next(); } 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); @@ -909,7 +849,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 { @@ -926,7 +866,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 = KwOpsState::new( "[PATCH] test".to_string(), "http://lore.example/123".to_string(), "linux".to_string(), @@ -934,30 +874,27 @@ mod tests { "path": "/kernel", "branch": "main" })) - .unwrap(), - crate::kw::readiness::KwReadiness { - kw_binary: crate::kw::readiness::KwBinaryProbe { + .expect("json parses"), + KwReadiness { + kw_binary: KwBinaryProbe { available: true, version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::readiness::KwVersionCheck::Meets, + check: KwVersionCheck::Meets, }, - tree: crate::kw::readiness::TreeReadiness::Ready { + tree: TreeReadiness::Ready { 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), + deploy_alone: Err(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(RemoteRefusal::NoRemotesConfigured), + boot_once: BootOnceState::Unknown, }, ); 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); @@ -973,37 +910,6 @@ 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( - "[PATCH] test".to_string(), - "http://lore.example/123".to_string(), - "linux".to_string(), - serde_json::from_value(serde_json::json!({ - "path": "/kernel", - "branch": "main" - })) - .unwrap(), - crate::kw::readiness::KwReadiness { - kw_binary: crate::kw::readiness::KwBinaryProbe { - available: true, - version_line: Some("kw, version 0.10.0".to_string()), - check: crate::kw::readiness::KwVersionCheck::Meets, - }, - tree: crate::kw::readiness::TreeReadiness::Ready { - 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), - boot_once: crate::kw::readiness::BootOnceState::Unknown, - }, - ) - } - #[test] fn cleared_readable_branch_does_not_claim_detached_head() { let mut state = app_state_with_kw(None); @@ -1012,7 +918,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); @@ -1027,7 +933,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 @@ -1050,7 +956,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); @@ -1065,7 +971,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); @@ -1077,26 +983,17 @@ mod tests { ); } - fn sample_remote() -> crate::kw::remote::KwRemote { - crate::kw::remote::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); 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(DeployAloneRefusal::NoBuildRecord); + ops.readiness.boot_once = 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); @@ -1116,11 +1013,11 @@ 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 = BootOnceState::Off; 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); @@ -1139,11 +1036,11 @@ 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 = BootOnceState::Unknown; 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); @@ -1154,17 +1051,16 @@ 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); - 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); @@ -1187,7 +1083,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!( @@ -1211,7 +1107,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); @@ -1234,7 +1130,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); @@ -1246,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(&[ @@ -1268,7 +1151,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); @@ -1290,14 +1173,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); @@ -1309,7 +1192,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); @@ -1319,14 +1202,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(), @@ -1349,7 +1235,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); diff --git a/src/cli.rs b/src/cli.rs index 7ff96b2d..9e3ed771 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 2eb64bb7..56d24381 100644 --- a/src/config/actor.rs +++ b/src/config/actor.rs @@ -1,29 +1,28 @@ //! 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, oneshot}, -}; +use tokio::{spawn, sync::mpsc}; +use crate::infrastructure::actor_reply::ActorReplyService; use crate::{ config::{ handle::ConfigHandle, messages::{ConfigMessage, ConfigResult}, - repository::{ConfigRepository, JsonConfigRepository}, - service::{ensure_directories, validate_update}, - state::{normalize_derived_paths, ConfigState}, - ConfigSnapshot, ConfigUpdateDraft, + service::ConfigService, + ConfigRepository, ConfigSnapshot, ConfigState, ConfigUpdateDraft, JsonConfigRepository, }, infrastructure::file_system::FileSystemTrait, }; 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, @@ -61,19 +60,35 @@ 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"); match message { ConfigMessage::GetSnapshot { reply } => { - send_config_snapshot_reply(message_name, reply, self.state.to_snapshot()); + ActorReplyService::deliver_value( + message_name, + REPLY_DROPPED_LOG, + reply, + ConfigSnapshot::from(&self.state), + ); ControlFlow::Continue(()) } ConfigMessage::ValidateAndApply { draft, reply } => { let result = self.apply(*draft); - send_config_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } ConfigMessage::Shutdown => { @@ -84,112 +99,86 @@ 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()) - } -} - -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" - ); + Ok(ConfigSnapshot::from(&self.state)) } } #[cfg(test)] mod tests { - use std::{ - env::VarError, - fs, - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, - }; - - use crate::{ - config::{ - service::bootstrap_parts, ConfigError, ConfigUpdateDraft, DEFAULT_CONFIG_PATH_SUFFIX, - }, - infrastructure::{env::MockEnvTrait, file_system::OsFileSystem}, - }; - - use super::*; - - 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())); - fs::create_dir_all(&p).unwrap(); - 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") - .returning(|_| Err(VarError::NotPresent.into())); - mock.expect_var() - .withf(move |key| key == "HOME") - .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" - ) - }) - .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::{self, VarError}, + fs, + path::PathBuf, + process, + 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 + } + + 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) + } - fn spawn_test_actor() -> (ConfigHandle, PathBuf) { - let (env, home) = default_env(); - let (state, repo) = bootstrap_parts(&env, OsFileSystem).unwrap(); - (ConfigActor::spawn(state, repo), 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() { 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; @@ -205,10 +194,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; } @@ -223,10 +219,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; } @@ -241,10 +237,17 @@ 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!(30, handle.get_snapshot().await.unwrap().page_size()); + assert_eq!( + 30, + handle + .get_snapshot() + .await + .expect("snapshot loads") + .page_size() + ); handle.shutdown().await; } @@ -253,7 +256,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/env_overrides.rs b/src/config/env_overrides.rs index 3774c8ba..9231aa09 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 onto `state`. + 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/handle.rs b/src/config/handle.rs index a0ae5dd4..a3cf763c 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/mod.rs b/src/config/mod.rs index f70e22a7..236ab53f 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,9 +14,9 @@ mod update; pub use actor::ConfigActor; pub use errors::ConfigError; pub use handle::ConfigHandle; -pub use repository::{resolve_config_path, ConfigRepository, JsonConfigRepository}; -pub(crate) use service::bootstrap_parts; -pub use state::{normalize_derived_paths, ConfigSnapshot, ConfigState, KernelTree}; +pub use repository::{ConfigRepository, JsonConfigRepository}; +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 40da9bd5..217a7d25 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 ae9e3e2e..d87077ce 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 cbf38e94..32f3a600 100644 --- a/src/config/service.rs +++ b/src/config/service.rs @@ -4,225 +4,241 @@ 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::parsing::ConfigParsingService; +use crate::config::{ + 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}"); + 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(()) } - 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 validate_update( + draft: ConfigUpdateDraft, + fs: &dyn FileSystemTrait, + current: &ConfigState, + ) -> Result { + 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(raw.to_string())) + }, + )?; + + let cache_dir = Self::parse_non_empty( + &draft.cache_dir, + |_| { + Err(ConfigError::InvalidDirectory( + "cache directory is empty".into(), + )) + }, + |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(), + )) + }, + |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(); + + 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(ConfigParsingService::parse_cover_renderer(s)?), + }; + + 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(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(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(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(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) { + Ok(Some(key.to_string())) + } else { + Err(Self::reject_unknown_kernel_tree(raw, 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, + }) } - 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()) +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}"); + } + } } - }; - - 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 cover_renderer = match &draft.cover_renderer { - None => None, - Some(s) => Some(parse_cover_renderer(s)?), - }; + ConfigState::new_with_defaults(env) + } - 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())); + fn reject_unknown_kernel_tree(raw: &str, current: &ConfigState) -> ConfigError { + 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" + .to_string() + } else { + format!( + "known keys: {}; unset the target or pick one of these", + keys.join(", ") + ) + }; + ConfigError::InvalidTargetKernelTree { + key: raw.to_string(), + hint, } - 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())) + } + + 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 { - return Err(unknown_target_kernel_tree(s, current)); + on_value(raw).map(Some) } + } else { + Ok(None) } - }; - - 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 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 b0f7ff30..92829618 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}; @@ -64,6 +64,59 @@ 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()); + } + } + + /// 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"); @@ -90,11 +143,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; } @@ -147,61 +195,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(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"); } /// Immutable view of configuration for the rest of the application (read-only). @@ -230,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 33ce2261..8339f6bc 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1,132 +1,148 @@ 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::{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, -}; -use crate::infrastructure::{ - env::{EnvTrait, MockEnvTrait}, - file_system::OsFileSystem, + ConfigError, ConfigUpdateDraft, ValidatedConfigUpdate, DEFAULT_CONFIG_PATH_SUFFIX, }; +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::{self, VarError}, + fs, + path::{Path, PathBuf}, + process, + sync::atomic::{AtomicU64, Ordering}, + }; + + pub static TEST_SEQ: AtomicU64 = AtomicU64::new(0); + + pub fn os_fs() -> OsFileSystem { + OsFileSystem + } -static TEST_SEQ: AtomicU64 = AtomicU64::new(0); - -fn os_fs() -> OsFileSystem { - OsFileSystem -} - -fn sample_kernel_tree(path: &str, branch: &str) -> KernelTree { - serde_json::from_value(json!({ - "path": path, - "branch": branch - })) - .unwrap() -} + 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 = std::env::temp_dir().join(format!("patch-hub-{prefix}-{}-{}", std::process::id(), n)); - fs::create_dir_all(&p).unwrap(); - 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 { - bootstrap_parts(env, os_fs()).unwrap().0.to_snapshot() -} + 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") - .returning(|_| Err(VarError::NotPresent.into())); - mock.expect_var() - .withf(move |key| key == "HOME") - .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" - ) - }) - .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).unwrap() + /// 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() { @@ -173,7 +189,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"); @@ -182,9 +198,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| { @@ -197,6 +215,7 @@ fn bootstrap_with_config_file() { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(5) .returning(|_| Err(VarError::NotPresent.into())); let config = bootstrap_snapshot(&mock); @@ -236,13 +255,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", @@ -270,24 +293,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); @@ -326,7 +356,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(); @@ -334,10 +364,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() @@ -351,6 +383,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()); @@ -361,14 +394,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() @@ -381,6 +417,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()); @@ -394,9 +431,9 @@ 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.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. @@ -412,6 +449,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") @@ -419,6 +457,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| { @@ -430,9 +469,10 @@ fn bootstrap_rejects_invalid_patch_hub_page_size_env() { | "PATCH_HUB_PATCH_RENDERER" ) }) + .times(0) .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")) @@ -447,6 +487,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") @@ -454,21 +495,26 @@ 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 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 +535,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 +555,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() @@ -517,13 +563,13 @@ 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")); } #[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() @@ -531,7 +577,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" @@ -540,7 +586,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() @@ -548,7 +594,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" @@ -557,7 +603,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() @@ -565,7 +611,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" @@ -575,7 +621,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() @@ -583,7 +629,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 @@ -616,7 +662,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), @@ -625,15 +671,15 @@ 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.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:?}" @@ -667,7 +713,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() @@ -675,7 +721,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); } @@ -683,7 +729,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() @@ -691,7 +737,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); } @@ -699,7 +745,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() @@ -707,7 +753,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" @@ -744,8 +790,8 @@ 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(); - let err = validate_update( + fs::write(&blocking, b"x").expect("file writes"); + let err = ConfigService::validate_update( ConfigUpdateDraft { cache_dir: Some(blocking.to_string_lossy().into_owned()), ..Default::default() @@ -753,7 +799,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(_))); } @@ -768,18 +814,20 @@ 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()); 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")) }); @@ -792,7 +840,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()).expect("config bootstraps"); let handle = ConfigActor::spawn(state, repo); let cfg_path = home.join(DEFAULT_CONFIG_PATH_SUFFIX); @@ -802,11 +850,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; } @@ -814,7 +862,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) = 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"), @@ -828,11 +877,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 @@ -841,9 +890,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; } @@ -851,7 +900,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) = 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"), @@ -865,7 +915,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" @@ -875,7 +925,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/config/update.rs b/src/config/update.rs index 2ee3b476..9dc1cb20 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/actor_reply.rs b/src/infrastructure/actor_reply.rs new file mode 100644 index 00000000..051c757e --- /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/env/tests.rs b/src/infrastructure/env/tests.rs index 527dce4c..6b18cb19 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/errors.rs b/src/infrastructure/errors.rs index 452e926b..25b0f1dc 100644 --- a/src/infrastructure/errors.rs +++ b/src/infrastructure/errors.rs @@ -1,4 +1,9 @@ -use std::{error::Error, panic}; +use std::{ + error::Error, + io::{self, Write}, + panic, + sync::atomic::{AtomicBool, Ordering}, +}; use color_eyre::{ config::HookBuilder, @@ -7,26 +12,58 @@ use color_eyre::{ use super::terminal::restore; -/// This replaces the standard color_eyre panic and error hooks with hooks that -/// restore the terminal before printing the panic or error. -/// -/// 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. +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 { + 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}"); + } + } +} + +/// 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(); // 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(); + 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)| { - restore().unwrap(); eyre_hook(error) }))?; @@ -35,11 +72,15 @@ pub fn install_hooks() -> Result<()> { #[cfg(test)] 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 @@ -53,8 +94,23 @@ 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()); } + + #[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)); + } } diff --git a/src/infrastructure/file_system/mod.rs b/src/infrastructure/file_system/mod.rs index 288f333f..e66fded3 100644 --- a/src/infrastructure/file_system/mod.rs +++ b/src/infrastructure/file_system/mod.rs @@ -7,10 +7,11 @@ pub use r#trait::{FileSystemError, FileSystemTrait}; #[cfg(test)] pub use r#trait::MockFileSystemTrait; +use std::path::PathBuf; use std::{ fs::{self, File}, io::{self, BufReader, Read, Seek, SeekFrom}, - path::{Path, PathBuf}, + path::Path, }; #[cfg(test)] diff --git a/src/infrastructure/file_system/tests.rs b/src/infrastructure/file_system/tests.rs index 6c3d1b73..46eae9a1 100644 --- a/src/infrastructure/file_system/tests.rs +++ b/src/infrastructure/file_system/tests.rs @@ -1,39 +1,16 @@ -use std::path::{Path, PathBuf}; +use std::{fs, path::Path}; use super::{FileSystemTrait, OsFileSystem}; - -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(); - Self(dir) - } - - fn path(&self) -> &Path { - &self.0 - } -} - -impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} +use crate::test_support::TempDir; #[test] 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").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 +27,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 = std::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 +39,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 +48,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, "").expect("file writes"); let fs = OsFileSystem; assert!(fs.exists(&file_path)); @@ -83,7 +60,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, "").expect("file writes"); let fs = OsFileSystem; assert!(fs.is_file(&file_path)); @@ -94,7 +71,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, "").expect("file writes"); let fs = OsFileSystem; assert!(fs.is_dir(dir.path())); @@ -104,13 +81,13 @@ 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"), "").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 +111,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").expect("file writes"); let fs = OsFileSystem; - fs.rename(&src, &dst).unwrap(); + fs.rename(&src, &dst).expect("path renames"); assert!(!src.exists()); - assert_eq!(std::fs::read_to_string(&dst).unwrap(), "rename me"); + assert_eq!(fs::read_to_string(&dst).expect("file reads"), "rename me"); } #[test] @@ -151,11 +128,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 = std::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 +142,15 @@ 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").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: Vec = 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 +158,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"); - std::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 +177,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"); - std::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 +192,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". - std::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"); - std::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"); - std::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/file_system/trait.rs b/src/infrastructure/file_system/trait.rs index 03ab2c37..8548dbfa 100644 --- a/src/infrastructure/file_system/trait.rs +++ b/src/infrastructure/file_system/trait.rs @@ -1,11 +1,8 @@ use mockall::automock; use thiserror::Error; -use std::{ - fs::Metadata, - io, - path::{Path, PathBuf}, -}; +use std::path::PathBuf; +use std::{fs::Metadata, io, path::Path}; #[derive(Debug, Error)] pub enum FileSystemError { diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 48908adf..cc96c4ed 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,9 +1,9 @@ +pub mod actor_reply; pub mod env; 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/infrastructure/monitoring/logging/mod.rs b/src/infrastructure/monitoring/logging/mod.rs index 312c593e..9a73b871 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 44caba64..df90b2a7 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; @@ -94,7 +97,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!( @@ -153,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() } @@ -168,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 dbf84178..cef88f05 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/tests.rs b/src/infrastructure/net/tests.rs index 2409fcd5..93227b9e 100644 --- a/src/infrastructure/net/tests.rs +++ b/src/infrastructure/net/tests.rs @@ -4,26 +4,31 @@ 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"); assert!(result.is_ok()); - assert_eq!("response", result.unwrap()); + assert_eq!("response", result.expect("request completes")); } #[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()); - let err = result.unwrap_err(); + let err = result.expect_err("request fails"); assert!(err.to_string().contains("404")); } diff --git a/src/infrastructure/net/trait.rs b/src/infrastructure/net/trait.rs index 02df3822..13ed14d6 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/fake.rs b/src/infrastructure/process/fake.rs index aab609b9..8a4e3c74 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/mod.rs b/src/infrastructure/process/mod.rs index f1e6d3ad..f5ee98b6 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 76df501b..94d711bd 100644 --- a/src/infrastructure/process/tests.rs +++ b/src/infrastructure/process/tests.rs @@ -1,78 +1,62 @@ -use std::{ - io, - os::unix::process::ExitStatusExt, - path::{Path, PathBuf}, - process::ExitStatus, - time::{Duration, Instant}, -}; +use std::{fs, io, os::unix::process::ExitStatusExt, process::ExitStatus, time::Duration}; -use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; +use tokio::time; use super::{ FakeProcess, MockProcessTrait, MockRunningProcess, OsProcess, ProcessError, ProcessTrait, RunningProcess, }; use crate::infrastructure::shell::ShellCommand; - -struct TempDir(PathBuf); - -impl TempDir { - fn new(test_name: &str) -> Self { - let dir = std::env::temp_dir().join(format!( - "patch_hub_process_test_{}_{test_name}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - Self(dir) +use crate::test_support::TempDir; + +mod helpers { + + use nix::{ + errno::Errno, + sys::signal::{kill, Signal}, + unistd::Pid, + }; + use std::{ + fs, + path::Path, + time::{Duration, Instant}, + }; + use tokio::time; + + 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; + } + true } - fn path(&self) -> &Path { - &self.0 + pub fn pid_is_gone(pid: i32) -> bool { + matches!(kill(Pid::from_raw(pid), None::), Err(Errno::ESRCH)) } -} -impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); + // 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") } } - -async fn wait_for(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool { - let start = Instant::now(); - while !cond() { - if start.elapsed() > timeout { - return false; - } - tokio::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) - ) -} - -// 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 = std::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() { @@ -80,15 +64,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 = std::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 = std::fs::read_to_string(&log).unwrap(); + let final_log = fs::read_to_string(&log).expect("file reads"); assert!(final_log.contains("done")); } @@ -98,8 +84,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)); @@ -112,12 +100,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 = std::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") @@ -127,9 +117,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 = std::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")); @@ -141,12 +131,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 = std::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()); } @@ -158,15 +150,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(); - let status = tokio::time::timeout(Duration::from_secs(2), process.wait()) + 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()); @@ -181,11 +175,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] @@ -208,7 +204,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; @@ -222,12 +220,18 @@ 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().unwrap(); - let status = process.wait().await.unwrap(); + process.kill().expect("process kills"); + let status = process.wait().await.expect("process exits"); assert!(status.success()); } @@ -238,7 +242,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]; @@ -251,14 +255,14 @@ 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).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()); } @@ -269,10 +273,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)); } @@ -284,12 +288,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()); } @@ -300,13 +304,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()); } @@ -318,13 +322,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()); } @@ -350,12 +354,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/infrastructure/process/trait.rs b/src/infrastructure/process/trait.rs index 5ce80709..4bcde99b 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/shell/tests.rs b/src/infrastructure/shell/tests.rs index 559213c3..85cc9abf 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/infrastructure/terminal.rs b/src/infrastructure/terminal.rs index 7c78a0b0..75f09d96 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 [`crate::terminal::session::CrosstermTerminalSession`], and fatal -//! error hooks 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::{ @@ -21,11 +20,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/input/actor.rs b/src/input/actor.rs index 71d6182d..8bccba75 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`](crate::input::event::InputEvent) values for the application. +//! Input actor: polls the terminal and maps events to `InputEvent`. //! -//! A dedicated pump subtask calls -//! [`TerminalHandle::poll_event`](crate::terminal::handle::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); -//! context updates from -//! [`InputHandle::update_context`](crate::input::handle::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}; @@ -128,7 +121,9 @@ impl InputActor { tracing::info!("input actor stopped"); } +} +impl InputActor { async fn deliver(&mut self, event: InputEvent) { let Some(tx) = &self.subscriber else { return; @@ -142,50 +137,79 @@ 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::new(CurrentScreen::MailingListSelection) - } - - fn details_context() -> InputContext { - InputContext::new(CurrentScreen::PatchsetDetails) - } - - 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(); // First poll returns the key; subsequent polls time-out (return None). session .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::Down))))); - session.expect_poll_event().returning(|_| Ok(None)); + .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); - 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)); @@ -197,23 +221,40 @@ 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() + .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::press(KeyCode::Esc))))); - session.expect_poll_event().returning(|_| Ok(None)); + .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); - 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)); @@ -225,19 +266,38 @@ mod tests { // F6 has no mapping in any screen — it should be silently dropped. session .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::F(6)))))); + .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() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::Down))))); - session.expect_poll_event().returning(|_| Ok(None)); + .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); - 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; @@ -247,13 +307,20 @@ 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); - 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. @@ -266,15 +333,22 @@ 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); - 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. @@ -287,14 +361,30 @@ mod tests { let mut session = MockTerminalSessionApi::new(); session .expect_poll_event() + .withf(|timeout| *timeout == std::time::Duration::from_millis(50)) .times(1) - .returning(|_| Ok(Some(TerminalEvent::Key(KeyInput::press(KeyCode::Esc))))); - session.expect_poll_event().returning(|_| Ok(None)); + .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 = 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(); + 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/input/context.rs b/src/input/context.rs index dc75591a..7d4229e5 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 cf188724..4319c7b4 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/handle.rs b/src/input/handle.rs index fef224d0..9e9527ad 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 4ea219ad..ea3253e1 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; @@ -214,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) { @@ -249,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::{ @@ -260,22 +292,14 @@ mod tests { }, }; - fn context(current_screen: CurrentScreen) -> InputContext { - InputContext::new(current_screen) - } - - fn key(code: KeyCode) -> TerminalEvent { - TerminalEvent::Key(KeyInput::press(code)) - } - - fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> TerminalEvent { - TerminalEvent::Key(KeyInput::modified_press(code, modifiers)) - } - #[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 +309,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 +370,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 +394,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 +432,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 +527,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/input/mod.rs b/src/input/mod.rs index ceee4aad..961f9002 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.rs b/src/kw/actor.rs deleted file mode 100644 index e2bfa2b9..00000000 --- a/src/kw/actor.rs +++ /dev/null @@ -1,3652 +0,0 @@ -//! kw actor: owns kw build/deploy job state and the kw history store. -//! -//! All kw operations go through [`KwHandle`](crate::kw::handle::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). -//! -//! Git checkout and readiness probes run on the blocking pool. -//! `create_dir_all`, history writes, and completion probes run inline -//! on the actor task. - -use std::{ - ops::ControlFlow, - path::{Path, PathBuf}, - process::ExitStatus, - sync::Arc, - time::Duration, -}; - -use tokio::{ - spawn, - sync::{mpsc, oneshot, watch}, -}; - -use crate::{ - config::KernelTree, - infrastructure::{ - env::EnvTrait, - file_system::FileSystemTrait, - process::{ProcessError, ProcessTrait, RunningProcess}, - shell::{ShellCommand, ShellTrait}, - }, - kw::{ - argv, - errors::{KwError, KwStartError, TreeGitError}, - handle::KwHandle, - history::{KwBuildRecord, KwHistoryStore}, - log_scan, - messages::{DeployOptions, KwMessage, StartRequest}, - readiness::{self, BootOnceState, KwReadiness, KwVersionCheck, TreeReadiness}, - remote::{self, KwRemote}, - status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, - }, -}; - -pub const DEFAULT_KW_CHANNEL_SIZE: usize = 16; - -/// 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 -/// must not wedge the actor into refusing every later Start with -/// JobAlreadyRunning for the rest of the session. -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, - job_event_tx: mpsc::Sender, - status_tx: watch::Sender, - history: Arc, - shell: Arc, - fs: Arc, - env: Arc, - process: Arc, - kw_log_dir: PathBuf, - job: Option, - /// Recorded only when a job is accepted: a refused start never - /// clobbers a previous job's restore target. - last_restore: Option, -} - -impl KwActor { - pub fn new( - rx: mpsc::Receiver, - history: Arc, - process: Arc, - shell: Arc, - fs: Arc, - env: Arc, - kw_log_dir: PathBuf, - ) -> Self { - let (status_tx, _) = watch::channel(KwStatusSnapshot::idle()); - let (job_event_tx, job_event_rx) = mpsc::channel(DEFAULT_KW_CHANNEL_SIZE); - Self { - rx, - job_event_rx, - job_event_tx, - status_tx, - history, - shell, - fs, - env, - process, - kw_log_dir, - job: None, - last_restore: None, - } - } - - pub fn spawn( - history: Arc, - process: Arc, - shell: Arc, - fs: Arc, - env: Arc, - kw_log_dir: PathBuf, - ) -> KwHandle { - let (tx, rx) = mpsc::channel(DEFAULT_KW_CHANNEL_SIZE); - tracing::debug!(channel_size = DEFAULT_KW_CHANNEL_SIZE, "spawning kw actor"); - spawn(Self::new(rx, history, process, shell, fs, env, kw_log_dir).run()); - KwHandle::new(tx) - } - - pub async fn run(mut self) { - tracing::info!("kw actor started"); - // The job-event sender is held by the actor itself, so that arm of - // the select never closes while the actor is alive. - // Prefer control messages over job events so a Cancel that races a - // successful build exit is observed before BuildThenDeploy would - // chain into deploy. - loop { - tokio::select! { - biased; - message = self.rx.recv() => { - let Some(message) = message else { break }; - if let ControlFlow::Break(()) = self.handle_message(message).await { - break; - } - } - Some(event) = self.job_event_rx.recv() => { - self.handle_job_event(event); - } - } - } - tracing::info!("kw actor stopped"); - } - - async fn handle_message(&mut self, message: KwMessage) -> ControlFlow<()> { - let message_name = message.name(); - tracing::debug!(message = message_name, "kw request received"); - - match message { - KwMessage::RecordApply { record, reply } => { - send_kw_reply( - message_name, - reply, - self.history.record_apply(record).map_err(KwError::from), - ); - ControlFlow::Continue(()) - } - KwMessage::StartBuild { request, reply } => { - send_start_reply( - message_name, - reply, - self.start_job(KwJobKind::Build, request).await, - ); - ControlFlow::Continue(()) - } - KwMessage::StartDeploy { request, reply } => { - send_start_reply( - message_name, - reply, - self.start_job(KwJobKind::Deploy, request).await, - ); - ControlFlow::Continue(()) - } - KwMessage::StartBuildThenDeploy { request, reply } => { - send_start_reply( - message_name, - reply, - self.start_job(KwJobKind::BuildThenDeploy, request).await, - ); - ControlFlow::Continue(()) - } - KwMessage::Cancel { reply } => { - send_kw_reply(message_name, reply, self.request_cancel()); - ControlFlow::Continue(()) - } - KwMessage::GetStatus { reply } => { - send_value_reply(message_name, reply, self.status_tx.borrow().clone()); - ControlFlow::Continue(()) - } - KwMessage::WatchStatus { reply } => { - send_value_reply(message_name, reply, self.status_tx.subscribe()); - ControlFlow::Continue(()) - } - KwMessage::GetReadiness { - kernel_tree_id, - tree, - for_branch, - reply, - } => { - send_kw_reply( - message_name, - reply, - self.evaluate_readiness(&kernel_tree_id, &tree, for_branch) - .await, - ); - ControlFlow::Continue(()) - } - KwMessage::RestorePreviousBranch { reply } => { - send_kw_reply(message_name, reply, self.restore_previous_branch().await); - ControlFlow::Continue(()) - } - 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. - if self.job.is_some() { - tracing::info!("killing kw job process group during shutdown"); - self.request_cancel().ok(); - while self.job.is_some() { - // `None` is unreachable — the actor holds - // `job_event_tx`, so the channel never closes — - // but break defensively rather than spin. - match self.job_event_rx.recv().await { - Some(event) => self.handle_job_event(event), - None => break, - } - } - } - reply.send(()).ok(); - tracing::debug!("kw actor shutting down"); - ControlFlow::Break(()) - } - } - } - - /// Accepts and starts a job, or refuses. The reply is sent by the - /// caller right after this returns; the job keeps running in a - /// detached task. A start refused after the branch switch rolls - /// the switch back. - async fn start_job( - &mut self, - kind: KwJobKind, - request: StartRequest, - ) -> Result<(), KwStartError> { - if self.job.is_some() { - return Err(KwStartError::JobAlreadyRunning); - } - - 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). - let fs = Arc::clone(&self.fs); - 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 = readiness::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()); - Ok((kw_binary, output_dir, tree_readiness)) - }) - .await - .map_err(|error| KwStartError::GitStateProbe(error.to_string()))??; - if let KwVersionCheck::Below(version_line) = &kw_binary.check { - tracing::warn!( - version = %version_line, - minimum = ?readiness::KW_MIN_VERSION, - "kw reports a version below the verified floor" - ); - } - - // Unresolvable env state refuses the start: the build record this - // job writes at completion must know whether it ran under an O=. - // Both values are then snapshotted onto the job — the build runs - // under them, so the completion record describes them, not the - // tree's configuration at whatever time the job ends. - let TreeReadiness::Ready { arch } = tree_readiness else { - return Err(KwStartError::TreeNotReady(tree_readiness)); - }; - - let pre_job_branch = self.checkout_build_branch(&request).await?; - - let deploy = if matches!(kind, KwJobKind::Deploy | KwJobKind::BuildThenDeploy) { - match self - .prepare_deploy( - kind, - &request, - &tree_path, - output_dir.as_deref(), - arch.as_deref(), - ) - .await - { - Ok(deploy) => Some(deploy), - Err(error) => { - self.rollback_switch(request.tree.path(), pre_job_branch.as_deref()) - .await; - return Err(error); - } - } - } else { - None - }; - - let spawned = match kind { - KwJobKind::Deploy => self.spawn_deploy_process( - request.tree.path(), - deploy - .as_ref() - .expect("prepare_deploy returns context for Deploy"), - ), - KwJobKind::Build | KwJobKind::BuildThenDeploy => self.spawn_build_process(&request), - }; - let (process, log_path) = match spawned { - Ok(spawned) => spawned, - Err(error) => { - self.rollback_switch(request.tree.path(), pre_job_branch.as_deref()) - .await; - return Err(error); - } - }; - - let (cancel_tx, cancel_rx) = oneshot::channel(); - spawn(run_job(process, cancel_rx, self.job_event_tx.clone())); - - let phase = match kind { - KwJobKind::Deploy => KwPhase::Deploying, - KwJobKind::Build | KwJobKind::BuildThenDeploy => KwPhase::Building, - }; - match kind { - KwJobKind::Deploy => tracing::info!( - kernel_tree_id = request.kernel_tree_id, - branch = request.branch, - log_path = %log_path.display(), - "kw deploy job started" - ), - KwJobKind::Build | KwJobKind::BuildThenDeploy => tracing::info!( - kernel_tree_id = request.kernel_tree_id, - branch = request.branch, - log_path = %log_path.display(), - "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 - // did not check out. An accepted job with an unprobed pre-job - // HEAD clears the target: nothing honest is left to restore to. - self.last_restore = pre_job_branch.map(|branch| RestoreContext { - tree_path: request.tree.path().to_string(), - branch, - }); - self.job = Some(JobState { - kind, - phase, - kernel_tree_id: request.kernel_tree_id.clone(), - branch: request.branch.clone(), - tree_path: request.tree.path().to_string(), - output_dir, - arch, - log_path: log_path.clone(), - deploy, - cancel_tx: Some(cancel_tx), - }); - self.set_status(KwJobStatus::Running { - kind, - phase, - kernel_tree_id: request.kernel_tree_id, - branch: request.branch, - log_path, - }); - 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. - async fn prepare_deploy( - &self, - kind: KwJobKind, - request: &StartRequest, - tree_path: &Path, - output_dir: Option<&Path>, - arch: Option<&str>, - ) -> Result { - let fs = Arc::clone(&self.fs); - let env = Arc::clone(&self.env); - let history = Arc::clone(&self.history); - let request = request.clone(); - 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 || { - prepare_deploy_blocking( - kind, - &request, - &*fs, - &*env, - &*history, - &tree_path, - output_dir.as_deref(), - arch.as_deref(), - ) - }) - .await - .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 - /// would restore the branch the job switched to. An unprobed HEAD - /// (detached, or not a git repo) yields `None` rather than a wrong - /// branch. - async fn checkout_build_branch( - &self, - request: &StartRequest, - ) -> Result, KwStartError> { - 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 { - check_worktree_clean(&*shell, &tree_path)?; - let pre_job_branch = head_branch(&*shell, &tree_path); - 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 { - Some(pre_job_branch) - }) - } - - /// Switches the tree back after a start that reached the branch - /// switch but failed before accepting the job. A rollback failure is - /// logged, not reported: the caller already holds the actionable - /// refusal. An unprobed pre-job HEAD leaves nothing to roll back to. - async fn rollback_switch(&self, tree_path: &str, pre_job_branch: Option<&str>) { - let Some(branch) = pre_job_branch else { - tracing::warn!( - tree = tree_path, - "cannot roll back the branch switch: pre-job HEAD was unprobed" - ); - return; - }; - let shell = Arc::clone(&self.shell); - let tree_path = tree_path.to_string(); - 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) - }) - .await - { - Ok(Ok(())) => tracing::info!( - branch, - "rolled back the branch switch after a refused start" - ), - Ok(Err(error)) => { - let error = KwStartError::from(error); - tracing::warn!(%error, "failed to roll back the branch switch") - } - Err(error) => { - tracing::warn!(%error, "branch-switch rollback task failed to join") - } - } - } - - /// Creates the job's log dir and spawns the kw process. Split from - /// `start_job` so a failure here can roll the branch switch back. - fn spawn_build_process( - &self, - request: &StartRequest, - ) -> Result<(Box, PathBuf), KwStartError> { - self.fs.create_dir_all(&self.kw_log_dir)?; - // Millisecond suffix: two jobs started within the same second must - // 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") - )); - let cmd = ShellCommand::new("kw").args(argv::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)) - } - - /// Creates the job's log dir and spawns `kw deploy`. Split from - /// `start_job` so a failure here can roll the branch switch back. - fn spawn_deploy_process( - &self, - tree_path: &str, - deploy: &JobDeploy, - ) -> Result<(Box, PathBuf), KwStartError> { - 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") - )); - let cmd = ShellCommand::new("kw").args(argv::deploy_argv( - &deploy.remote.endpoint(), - deploy.options.reboot, - deploy.options.force, - &deploy.extra_args, - )); - let cwd = PathBuf::from(tree_path); - let process = self.process.spawn(&cmd, &cwd, &log_path)?; - Ok((process, log_path)) - } - - fn request_cancel(&mut self) -> Result<(), KwError> { - match self.job.as_mut() { - Some(job) => { - if let Some(cancel) = job.cancel_tx.take() { - let _ = cancel.send(()); - } - Ok(()) - } - None => Err(KwError::NoJobRunning), - } - } - - fn handle_job_event(&mut self, event: JobEvent) { - match event { - JobEvent::Finished(outcome) => { - let Some(job) = self.job.take() else { - tracing::warn!("kw job finished with no job state recorded"); - return; - }; - let outcome = outcome_after_building_cancel(&job, outcome); - let job = match self.advance_build_then_deploy(job, &outcome) { - 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. - 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::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::first_error(&log)), - log_path: job.log_path, - } - } - JobOutcome::WaitFailed(error) => { - tracing::warn!(branch = job.branch, %error, "failed to wait on kw job"); - KwJobStatus::Failed { - kind: job.kind, - phase: job.phase, - exit_code: None, - first_error: self - .read_job_log(&job.log_path) - .and_then(|log| log_scan::first_error(&log)), - log_path: job.log_path, - } - } - JobOutcome::Cancelled => { - tracing::info!(branch = job.branch, "kw job cancelled"); - KwJobStatus::Cancelled { - kind: job.kind, - phase: job.phase, - log_path: job.log_path, - } - } - }; - self.set_status(status); - } - } - } - - /// After a successful BuildThenDeploy build, write the build record - /// and spawn the deploy process. `Break` means this Finished was - /// consumed (job still running, or failed at the deploy spawn - /// boundary). `Continue` hands the job back for a normal terminal. - fn advance_build_then_deploy( - &mut self, - job: JobState, - outcome: &JobOutcome, - ) -> ControlFlow<(), JobState> { - let JobOutcome::Exited(exit) = outcome else { - return ControlFlow::Continue(job); - }; - if job.kind != KwJobKind::BuildThenDeploy - || job.phase != KwPhase::Building - || !exit.success() - // A cancel during Building must not deploy, even if the build - // process exited 0 (wait racing the cancel arm, or a trapped - // SIGTERM). `cancel_tx` is taken when Cancel is requested. - || job.cancel_tx.is_none() - { - 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. - let kernelrelease = self.record_build_outcome(&job, outcome); - let Some(mut deploy) = job.deploy.clone() else { - tracing::error!("BuildThenDeploy missing deploy context after a successful build"); - self.set_status(KwJobStatus::Failed { - kind: job.kind, - phase: KwPhase::Deploying, - exit_code: None, - log_path: job.log_path, - first_error: None, - }); - 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(); - spawn(run_job(process, cancel_rx, self.job_event_tx.clone())); - tracing::info!( - kernel_tree_id = job.kernel_tree_id, - branch = job.branch, - log_path = %log_path.display(), - "kw deploy phase started" - ); - self.set_status(KwJobStatus::Running { - kind: job.kind, - phase: KwPhase::Deploying, - kernel_tree_id: job.kernel_tree_id.clone(), - branch: job.branch.clone(), - log_path: log_path.clone(), - }); - let mut job = job; - job.phase = KwPhase::Deploying; - job.log_path = log_path; - job.deploy = Some(deploy); - job.cancel_tx = Some(cancel_tx); - self.job = Some(job); - ControlFlow::Break(()) - } - Err(error) => { - tracing::warn!( - %error, - branch = job.branch, - "failed to spawn kw deploy after a successful build" - ); - self.set_status(KwJobStatus::Failed { - kind: job.kind, - phase: KwPhase::Deploying, - exit_code: None, - log_path: job.log_path, - first_error: None, - }); - ControlFlow::Break(()) - } - } - } - - /// Persist a finished build (success or failure). Cancel writes - /// nothing. History and patchset-link errors are logged, not folded - /// into job status — the build's real outcome already reached the user. - /// Returns the recorded kernelrelease, if the build produced one. - fn record_build_outcome(&self, job: &JobState, outcome: &JobOutcome) -> Option { - if job.kind == KwJobKind::Deploy || job.phase == KwPhase::Deploying { - return None; - } - let success = match outcome { - JobOutcome::Exited(exit) => exit.success(), - // The exit status is lost: record an honest failure rather - // than guess, so deploy-alone readiness cannot trust it. - JobOutcome::WaitFailed(_) => false, - 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. - 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 - // successful one behind; only successes record what they - // 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), - ) - } else { - (None, None) - }; - let message_id = match self - .history - .apply_record_for_branch(&job.kernel_tree_id, &job.branch) - { - Ok(record) => record.map(|record| record.message_id), - Err(error) => { - tracing::warn!( - %error, - branch = job.branch, - "build record loses its patchset link" - ); - None - } - }; - - let record = KwBuildRecord { - kernel_tree_id: job.kernel_tree_id.clone(), - tree_path: job.tree_path.clone(), - message_id, - branch: job.branch.clone(), - arch: job.arch.clone(), - image_path: image_path.map(|path| path.to_string_lossy().into_owned()), - output_dir: job - .output_dir - .as_ref() - .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(), - success, - }; - if let Err(error) = self.history.record_build(record) { - tracing::warn!(%error, branch = job.branch, "failed to record kw build history"); - } - kernelrelease - } - - /// Reads a finished job's log for the findings its terminal status - /// carries. An unreadable log only loses those findings: the exit code - /// is still the verdict. - fn read_job_log(&self, log_path: &Path) -> Option { - match self.fs.read_to_string(log_path) { - Ok(log) => Some(log), - Err(error) => { - tracing::warn!(%error, log_path = %log_path.display(), "failed to read kw job log"); - None - } - } - } - - fn set_status(&mut self, job: KwJobStatus) { - // send_replace, not send: no receiver (nobody called WatchStatus - // yet) is a normal state, not an error. Restore availability is - // published with every snapshot so the UI does not have to guess - // whether RestorePreviousBranch would succeed. - self.status_tx.send_replace(KwStatusSnapshot { - job, - restore_branch: self - .last_restore - .as_ref() - .map(|restore| restore.branch.clone()), - }); - } - - /// Re-broadcasts the current job with the current restore projection. - /// Used after restore succeeds (the branch is gone) without changing - /// the job's own status. - fn republish_status(&mut self) { - let job = self.status_tx.borrow().job.clone(); - self.set_status(job); - } - - /// Git probes and history I/O run on the blocking pool so a slow - /// tree cannot stall Cancel (or any other message) for the duration. - async fn evaluate_readiness( - &self, - kernel_tree_id: &str, - tree: &KernelTree, - for_branch: Option, - ) -> Result { - let fs = Arc::clone(&self.fs); - let env = Arc::clone(&self.env); - let shell = Arc::clone(&self.shell); - let history = Arc::clone(&self.history); - 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()); - readiness::evaluate_readiness( - &*fs, - &*env, - &*shell, - &*history, - &kernel_tree_id, - &tree, - &head, - for_branch.as_deref(), - ) - .map_err(KwError::from) - }) - .await - .map_err(|error| KwError::GitStateProbe(error.to_string()))? - } - - /// Switches the tree that ran the last job back to the branch HEAD was - /// on when that job was accepted. Refuses while a job is - /// running (its branch is in use), when nothing was recorded, and on - /// a dirty worktree. Only a successful switch consumes the context — - /// a refused restore stays available for a retry. - async fn restore_previous_branch(&mut self) -> Result<(), KwError> { - if self.job.is_some() { - return Err(KwError::JobRunning); - } - let Some(restore) = self.last_restore.take() else { - return Err(KwError::NoRecordedBranch); - }; - let shell = Arc::clone(&self.shell); - let tree_path = restore.tree_path.clone(); - 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> { - check_worktree_clean(&*shell, &tree_path)?; - switch_to_branch(&*shell, &tree_path, &branch) - }) - .await - .map_err(|error| KwError::GitStateProbe(error.to_string())) - .and_then(|result| result.map_err(KwError::from)); - match result { - Ok(()) => { - tracing::info!( - tree = restore.tree_path, - branch = restore.branch, - "restored pre-job branch" - ); - self.republish_status(); - Ok(()) - } - Err(error) => { - self.last_restore = Some(restore); - Err(error) - } - } - } -} - -/// 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. -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::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::find_newest_kernel_image(fs, output_dir.unwrap_or(tree_path), arch); - readiness::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::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, - }) -} - -/// 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(()) -} - -/// 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(()) -} - -/// 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() - } - } -} - -/// 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(); -} - -/// 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 - } - } - } - } -} - -/// 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 - } -} - -/// 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; - - 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), - } -} - -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 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(); - } -} diff --git a/src/kw/actor/actor_test.rs b/src/kw/actor/actor_test.rs new file mode 100644 index 00000000..3a0816b4 --- /dev/null +++ b/src/kw/actor/actor_test.rs @@ -0,0 +1,2335 @@ +use std::{ + env, fs, io, + path::Path, + sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, + }, + time::Duration, +}; + +use chrono::DateTime; +use tokio::time; + +use crate::{ + infrastructure::{ + env::MockEnvTrait, + file_system::{FileSystemError, MockFileSystemTrait}, + process::FakeProcess, + shell::{MockShellTrait, ShellOutput}, + }, + kw::{ + errors::KwStartError, + history::MockKwHistoryStore, + models::{ + readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}, + remote::RemoteRefusal, + }, + status::{KwJobKind, KwJobStatus, KwPhase}, + }, +}; + +use super::*; + +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, fs, io, + path::Path, + process, + 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 + } + + 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") + } + + 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, + } + } + + /// 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), + ) + } + + 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); + + pub fn command_parts(cmd: &ShellCommand) -> Vec { + let mut parts = vec![cmd.program.clone()]; + parts.extend(cmd.args.clone()); + parts + } + + pub 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`. + 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() + .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) + } + + /// 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>>, + } + + 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 + } + } + + 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", + ))) + }); + } + + /// 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. + 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. + 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 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 fn deploy_ready_fs() -> MockFileSystemTrait { + deploy_fs(DEPLOY_REMOTE_CONFIG, DEPLOY_BOOT_ONCE_OFF, true) + } + + 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 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 deploy_options(acknowledged: bool) -> DeployOptions { + DeployOptions { + reboot: false, + force: true, + boot_once_acknowledged: acknowledged, + } + } + + 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 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 fn env_with_kw() -> MockEnvTrait { + let mut env = MockEnvTrait::new(); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + } + + 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 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 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 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. + 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) + } + + 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(), + } + } + + /// 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() { + 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 + .expect("apply records"); + handle.shutdown().await; +} + +#[tokio::test] +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( + "record-apply-error", + history, + MockShellTrait::new(), + MockFileSystemTrait::new(), + MockEnvTrait::new(), + ); + + let err = handle + .record_apply(apply_record()) + .await + .expect_err("apply record fails"); + + 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.expect("status loads"); + + 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.expect("status watch opens"); + + 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") + .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() + .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() + .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 + .expect("readiness loads"); + + 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") + .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() + .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() + .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 + .expect("readiness loads"); + + assert_eq!(Some("master".to_string()), readiness.current_branch); + handle.shutdown().await; +} + +#[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 = time::timeout(Duration::from_secs(1), handle.start_build(start_request())) + .await + .expect("start_build must reply immediately"); + result.expect("build starts"); + + 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.expect("status loads"); + 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.expect("status watch opens"); + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!( + status, + KwJobStatus::Succeeded { + kind: KwJobKind::Build, + .. + } + ), + "unexpected status: {status:?}" + ); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("build starts"); + + 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; + 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 + .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).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.expect("status watch opens"); + + 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\ + 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; + 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.expect("status watch opens"); + + 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") + .expect("job cancels"); + + 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; + 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 + .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).expect("temp dir removes"); +} + +#[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 + .expect_err("spawn fails"); + + assert!(matches!(err, KwStartError::Spawn(_))); + 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 + .expect("build starts"); + + process.last_child().finish(0); + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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() + .withf(|name| name == "kw") + .times(1) + .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 + .expect_err("missing kw refuses build"); + + assert!(matches!(err, KwStartError::KwBinaryMissing)); + 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).expect("temp dir removes"); +} + +#[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() + .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(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() + .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()), + process.clone(), + Arc::new(shell), + Arc::new(fs), + Arc::new(env), + log_dir.clone(), + ); + + let err = handle + .start_build(start_request()) + .await + .expect_err("unreadiness refuses build"); + + assert!(matches!( + err, + KwStartError::TreeNotReady(TreeReadiness::MissingKwDir) + )); + 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).expect("temp dir removes"); +} + +#[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 + .expect("build starts"); + assert_eq!(1, process.spawned().len()); + + process.last_child().finish(0); + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect("build starts"); + + { + let calls = calls.lock().expect("calls locks"); + 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; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("dirty worktree refuses build"); + + assert!(matches!(err, KwStartError::DirtyWorktree)); + 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() + .expect("calls locks") + .iter() + .any(|call| call.iter().any(|part| part == "switch"))); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("unverifiable git refuses build"); + + assert!(matches!(err, KwStartError::GitStateProbe(_))); + assert!(err.to_string().contains("not a git repository")); + 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).expect("temp dir removes"); +} + +#[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 + .expect_err("branch switch refuses build"); + + assert!(matches!(err, KwStartError::CheckoutFailed(_))); + assert!(err.to_string().contains("did not match")); + 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).expect("temp dir removes"); +} + +#[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 = time::timeout(Duration::from_secs(1), handle.cancel()) + .await + .expect("cancel must reply immediately"); + let restore = 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.expect("status watch opens"); + + 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 + .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 + .expect("status loads") + .restore_branch + .as_deref() + ); + + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); + assert_eq!(git.head(), "master"); + 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. + let err = handle + .restore_previous_branch() + .await + .expect_err("second restore has no branch"); + assert!(matches!(err, KwError::NoRecordedBranch)); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect("build starts"); + 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 + // ends. + process.last_child().finish(0); + let mut watch = handle.watch_status().await.expect("status watch opens"); + let _ = wait_for_terminal_status(&mut watch).await; + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + handle + .start_build(start_request()) + .await + .expect("build starts"); + process.last_child().finish(0); + let _ = wait_for_terminal_status(&mut watch).await; + + git.set_dirty(true); + 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"); + assert_eq!( + Some("master"), + 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 + .expect("previous branch restores"); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + handle + .start_build(start_request()) + .await + .expect("build starts"); + 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 + .expect_err("restore fails"); + assert!(matches!(err, KwError::CheckoutFailed(_))); + assert!(err.to_string().contains("resolve your current index")); + + git.fail_switches_to(None); + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + 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"); + + // 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 + .expect_err("refused start stays refused"); + assert!(matches!(err, KwStartError::Spawn(_))); + assert_eq!(git.head(), "patchset-2026-08-01-17-30-00"); + + handle + .restore_previous_branch() + .await + .expect("previous branch restores"); + assert_eq!(git.head(), "master"); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect_err("actor has stopped"); + + 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() + .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 + .expect_err("log dir failure refuses start"); + + assert!(matches!(err, KwStartError::Fs(_))); + 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. + assert_eq!(git.head(), "master"); + assert!(matches!( + handle.restore_previous_branch().await, + Err(KwError::NoRecordedBranch) + )); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("spawn stays refused"); + + 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; + 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.expect("status watch opens"); + + 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 + // 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; + 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.expect("status watch opens"); + + 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 + // 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; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +// 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.expect("status watch opens"); + + 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 { .. }), + "expected Cancelled, got {status:?}" + ); + let child = process.last_child(); + assert!(child.was_killed()); + assert!(child.was_force_killed()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.expect("status watch opens"); + + 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().expect("builds locks"); + 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().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).expect("temp dir removes"); +} + +#[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() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.expect("status watch opens"); + + 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!( + status, + KwJobStatus::Failed { + exit_code: Some(2), + .. + } + )); + + { + let builds = builds.lock().expect("builds locks"); + 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; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.expect("status watch opens"); + + 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().expect("builds locks").is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.expect("status watch opens"); + process.fail_waits(true); + + handle + .start_build(start_request()) + .await + .expect("build starts"); + 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().expect("builds locks"); + assert_eq!(1, builds.len()); + assert!(!builds[0].success); + } + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[tokio::test] +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() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.expect("status watch opens"); + + handle + .start_build(start_request()) + .await + .expect("build starts"); + 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; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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() + .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() + .withf(|name| name == "kw") + .times(1) + .returning(|_| true); + env + }); + let mut watch = handle.watch_status().await.expect("status watch opens"); + + 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().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).expect("temp dir removes"); +} + +#[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() + .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() + .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()) + } 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() + .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() + .withf(|path| path.starts_with(std::env::temp_dir())) + .times(1) + .returning(|_| Ok(())); + let mut env = MockEnvTrait::new(); + 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); + let (handle, process, log_dir) = spawn_full_actor("env-build", history, shell, fs, env); + let mut watch = handle.watch_status().await.expect("status watch opens"); + + 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().expect("builds locks"); + 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; + 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 new file mode 100644 index 00000000..2dd699a1 --- /dev/null +++ b/src/kw/actor/deploy_test.rs @@ -0,0 +1,730 @@ +use std::fs; + +use tokio::time; + +use super::{actor_test::*, *}; +use crate::kw::{ + history::MockKwHistoryStore, + models::{readiness::DeployAloneRefusal, remote::RemoteRefusal}, +}; + +#[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 = time::timeout( + Duration::from_secs(1), + handle.start_deploy(deploy_request()), + ) + .await + .expect("start_deploy must reply immediately"); + result.expect("deploy starts"); + + 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() + .expect("path has a file name") + .to_string_lossy() + .starts_with("deploy-")); + + let snapshot = handle.get_status().await.expect("status loads"); + 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.expect("status watch opens"); + let status = wait_for_terminal_status(&mut watch).await; + assert!( + matches!( + status, + KwJobStatus::Succeeded { + kind: KwJobKind::Deploy, + .. + } + ), + "unexpected status: {status:?}" + ); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + 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\ + 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; + 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.expect("status watch opens"); + + handle + .start_build_then_deploy(deploy_request()) + .await + .expect("build then deploy starts"); + 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; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("deploy starts"); + + 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; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("deploy refused without a build"); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::NoBuildRecord) + )); + 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).expect("temp dir removes"); +} + +#[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 + .expect_err("failed build refuses deploy"); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::LastBuildFailed) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("tree drift refuses deploy"); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::TreePathDrift { .. }) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("output dir mismatch refuses deploy"); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::OutputDirMismatch) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("missing image refuses deploy"); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::ImageMissing) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("unresolved remote refuses deploy"); + + assert!(matches!( + err, + KwStartError::RemoteUnresolved(RemoteRefusal::NoRemotesConfigured) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("unacked boot-once refuses deploy"); + + assert!(matches!(err, KwStartError::BootOnceNotAcknowledged)); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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() + .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().withf(|_| true).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 + .expect_err("other branch refuses deploy"); + + assert!(matches!( + err, + KwStartError::DeployAloneRefused(DeployAloneRefusal::HeadMismatch { .. }) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("deploy starts"); + assert_eq!(1, process.spawned().len()); + + process.last_child().finish(0); + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("post-switch refusal rolls back"); + + 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; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + let mut request = deploy_request(); + request.extra_args = ["--verbose", "--ccache", "--alert=n"] + .into_iter() + .map(String::from) + .collect(); + time::timeout( + Duration::from_secs(1), + handle.start_build_then_deploy(request), + ) + .await + .expect("start_build_then_deploy must reply immediately") + .expect("build then deploy starts"); + + 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.expect("status loads").job, + KwJobStatus::Running { + kind: KwJobKind::BuildThenDeploy, + phase: KwPhase::Building, + .. + } + ), + "unexpected status: {:?}", + handle.get_status().await.expect("status loads").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() + .expect("path has a file name") + .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().expect("builds locks"); + 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().expect("builds locks").len()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + handle + .start_build_then_deploy(deploy_request()) + .await + .expect("build then deploy starts"); + 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().expect("builds locks"); + assert_eq!(1, builds.len()); + assert!(!builds[0].success); + } + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + handle + .start_build_then_deploy(deploy_request()) + .await + .expect("build then deploy starts"); + handle.cancel().await.expect("job cancels"); + + 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().expect("builds locks").is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + process.ignore_sigterm(true); + handle + .start_build_then_deploy(deploy_request()) + .await + .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; + 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().expect("builds locks").is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + handle + .start_build_then_deploy(deploy_request()) + .await + .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().expect("builds locks").len()); + + handle.cancel().await.expect("job cancels"); + 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().expect("builds locks"); + assert_eq!(1, builds.len()); + assert!(builds[0].success); + } + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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.expect("status watch opens"); + + handle + .start_build_then_deploy(deploy_request()) + .await + .expect("build then deploy starts"); + 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().expect("builds locks"); + assert_eq!(1, builds.len()); + assert!(builds[0].success); + } + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} + +#[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 + .expect_err("unresolved remote refuses chain"); + + assert!(matches!( + err, + KwStartError::RemoteUnresolved(RemoteRefusal::NoRemotesConfigured) + )); + assert!(process.spawned().is_empty()); + + handle.shutdown().await; + fs::remove_dir_all(&log_dir).expect("temp dir removes"); +} diff --git a/src/kw/actor/mod.rs b/src/kw/actor/mod.rs new file mode 100644 index 00000000..c6d70ab5 --- /dev/null +++ b/src/kw/actor/mod.rs @@ -0,0 +1,1178 @@ +//! kw actor: owns build/deploy job state and the history store. +//! +//! `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, + path::{Path, PathBuf}, + process::ExitStatus, + sync::Arc, + time::Duration, +}; + +use chrono::Utc; +use tokio::{ + spawn, + sync::{mpsc, oneshot, watch}, + task, time, +}; + +use crate::infrastructure::actor_reply::ActorReplyService; +use crate::{ + config::KernelTree, + infrastructure::{ + env::EnvTrait, + file_system::FileSystemTrait, + process::{ProcessError, ProcessTrait, RunningProcess}, + shell::{ShellCommand, ShellTrait}, + }, + kw::{ + argv, + errors::{KwError, KwStartError, TreeGitError}, + handle::KwHandle, + history::KwHistoryStore, + log_scan, + messages::{DeployOptions, KwMessage, StartRequest}, + models::{ + history::KwBuildRecord, + job::{JobDeploy, JobEvent, JobOutcome, JobState, RestoreContext}, + readiness::{BootOnceState, KwReadiness, KwVersionCheck, TreeReadiness}, + }, + readiness, remote, + status::{KwJobKind, KwJobStatus, KwPhase, KwStatusSnapshot}, + }, +}; + +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 +/// must not wedge the actor into refusing every later Start with +/// JobAlreadyRunning for the rest of the session. +const TERM_GRACE: Duration = Duration::from_secs(3); +const KILL_GRACE: Duration = Duration::from_secs(2); + +pub struct KwActor { + rx: mpsc::Receiver, + job_event_rx: mpsc::Receiver, + job_event_tx: mpsc::Sender, + status_tx: watch::Sender, + history: Arc, + shell: Arc, + fs: Arc, + env: Arc, + process: Arc, + kw_log_dir: PathBuf, + job: Option, + /// Recorded only when a job is accepted: a refused start never + /// clobbers a previous job's restore target. + last_restore: Option, +} + +impl KwActor { + pub fn new( + rx: mpsc::Receiver, + history: Arc, + process: Arc, + shell: Arc, + fs: Arc, + env: Arc, + kw_log_dir: PathBuf, + ) -> Self { + let (status_tx, _) = watch::channel(KwStatusSnapshot::idle()); + let (job_event_tx, job_event_rx) = mpsc::channel(DEFAULT_KW_CHANNEL_SIZE); + Self { + rx, + job_event_rx, + job_event_tx, + status_tx, + history, + shell, + fs, + env, + process, + kw_log_dir, + job: None, + last_restore: None, + } + } + + pub fn spawn( + history: Arc, + process: Arc, + shell: Arc, + fs: Arc, + env: Arc, + kw_log_dir: PathBuf, + ) -> KwHandle { + let (tx, rx) = mpsc::channel(DEFAULT_KW_CHANNEL_SIZE); + tracing::debug!(channel_size = DEFAULT_KW_CHANNEL_SIZE, "spawning kw actor"); + spawn(Self::new(rx, history, process, shell, fs, env, kw_log_dir).run()); + KwHandle::new(tx) + } + + pub async fn run(mut self) { + tracing::info!("kw actor started"); + // The job-event sender is held by the actor itself, so that arm of + // the select never closes while the actor is alive. + // Prefer control messages over job events so a Cancel that races a + // successful build exit is observed before BuildThenDeploy would + // chain into deploy. + loop { + tokio::select! { + biased; + message = self.rx.recv() => { + let Some(message) = message else { break }; + if let ControlFlow::Break(()) = self.handle_message(message).await { + break; + } + } + Some(event) = self.job_event_rx.recv() => { + self.handle_job_event(event); + } + } + } + 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"); + + match message { + KwMessage::RecordApply { record, reply } => { + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + self.history.record_apply(record).map_err(KwError::from), + ); + ControlFlow::Continue(()) + } + KwMessage::StartBuild { request, reply } => { + ActorReplyService::send_actor_reply( + message_name, + START_REQUEST_REFUSED_LOG, + REPLY_DROPPED_LOG, + reply, + self.start_job(KwJobKind::Build, request).await, + ); + ControlFlow::Continue(()) + } + KwMessage::StartDeploy { request, reply } => { + ActorReplyService::send_actor_reply( + message_name, + START_REQUEST_REFUSED_LOG, + REPLY_DROPPED_LOG, + reply, + self.start_job(KwJobKind::Deploy, request).await, + ); + ControlFlow::Continue(()) + } + KwMessage::StartBuildThenDeploy { request, reply } => { + ActorReplyService::send_actor_reply( + message_name, + START_REQUEST_REFUSED_LOG, + REPLY_DROPPED_LOG, + reply, + self.start_job(KwJobKind::BuildThenDeploy, request).await, + ); + ControlFlow::Continue(()) + } + KwMessage::Cancel { reply } => { + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + self.request_cancel(), + ); + ControlFlow::Continue(()) + } + KwMessage::GetStatus { reply } => { + ActorReplyService::deliver_value( + message_name, + REPLY_DROPPED_LOG, + reply, + self.status_tx.borrow().clone(), + ); + ControlFlow::Continue(()) + } + KwMessage::WatchStatus { reply } => { + ActorReplyService::deliver_value( + message_name, + REPLY_DROPPED_LOG, + reply, + self.status_tx.subscribe(), + ); + ControlFlow::Continue(()) + } + KwMessage::GetReadiness { + kernel_tree_id, + tree, + for_branch, + reply, + } => { + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + self.evaluate_readiness(&kernel_tree_id, &tree, for_branch) + .await, + ); + ControlFlow::Continue(()) + } + KwMessage::RestorePreviousBranch { reply } => { + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + self.restore_previous_branch().await, + ); + ControlFlow::Continue(()) + } + 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 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(); + while self.job.is_some() { + // `None` is unreachable — the actor holds + // `job_event_tx`, so the channel never closes — + // but break defensively rather than spin. + match self.job_event_rx.recv().await { + Some(event) => self.handle_job_event(event), + None => break, + } + } + } + reply.send(()).ok(); + tracing::debug!("kw actor shutting down"); + ControlFlow::Break(()) + } + } + } + + /// Accepts and starts a job, or refuses. The reply is sent by the + /// caller right after this returns; the job keeps running in a + /// detached task. A start refused after the branch switch rolls + /// the switch back. + async fn start_job( + &mut self, + kind: KwJobKind, + request: StartRequest, + ) -> Result<(), KwStartError> { + if self.job.is_some() { + return Err(KwStartError::JobAlreadyRunning); + } + + let tree_path = PathBuf::from(request.tree.path()); + // 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); + let tree_path_for_probe = tree_path.clone(); + 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); + } + 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 + .map_err(|error| KwStartError::GitStateProbe(error.to_string()))??; + if let KwVersionCheck::Below(version_line) = &kw_binary.check { + tracing::warn!( + version = %version_line, + minimum = ?readiness::KW_MIN_VERSION, + "kw reports a version below the verified floor" + ); + } + + // Unresolvable env state refuses the start: the build record this + // job writes at completion must know whether it ran under an O=. + // Both values are then snapshotted onto the job — the build runs + // under them, so the completion record describes them, not the + // tree's configuration at whatever time the job ends. + let TreeReadiness::Ready { arch } = tree_readiness else { + return Err(KwStartError::TreeNotReady(tree_readiness)); + }; + + let pre_job_branch = self.checkout_build_branch(&request).await?; + + let deploy = if matches!(kind, KwJobKind::Deploy | KwJobKind::BuildThenDeploy) { + match self + .prepare_deploy( + kind, + &request, + &tree_path, + output_dir.as_deref(), + arch.as_deref(), + ) + .await + { + Ok(deploy) => Some(deploy), + Err(error) => { + self.rollback_switch(request.tree.path(), pre_job_branch.as_deref()) + .await; + return Err(error); + } + } + } else { + None + }; + + let spawned = match kind { + KwJobKind::Deploy => self.spawn_deploy_process( + request.tree.path(), + deploy + .as_ref() + .expect("prepare_deploy returns context for Deploy"), + ), + KwJobKind::Build | KwJobKind::BuildThenDeploy => self.spawn_build_process(&request), + }; + let (process, log_path) = match spawned { + Ok(spawned) => spawned, + Err(error) => { + self.rollback_switch(request.tree.path(), pre_job_branch.as_deref()) + .await; + return Err(error); + } + }; + + let (cancel_tx, cancel_rx) = oneshot::channel(); + spawn(Self::run_job(process, cancel_rx, self.job_event_tx.clone())); + + let phase = match kind { + KwJobKind::Deploy => KwPhase::Deploying, + KwJobKind::Build | KwJobKind::BuildThenDeploy => KwPhase::Building, + }; + match kind { + KwJobKind::Deploy => tracing::info!( + kernel_tree_id = request.kernel_tree_id, + branch = request.branch, + log_path = %log_path.display(), + "kw deploy job started" + ), + KwJobKind::Build | KwJobKind::BuildThenDeploy => tracing::info!( + kernel_tree_id = request.kernel_tree_id, + branch = request.branch, + log_path = %log_path.display(), + "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 + // did not check out. An accepted job with an unprobed pre-job + // HEAD clears the target: nothing honest is left to restore to. + self.last_restore = pre_job_branch.map(|branch| RestoreContext { + tree_path: request.tree.path().to_string(), + branch, + }); + self.job = Some(JobState { + kind, + phase, + kernel_tree_id: request.kernel_tree_id.clone(), + branch: request.branch.clone(), + tree_path: request.tree.path().to_string(), + output_dir, + arch, + log_path: log_path.clone(), + deploy, + cancel_tx: Some(cancel_tx), + }); + self.set_status(KwJobStatus::Running { + kind, + phase, + kernel_tree_id: request.kernel_tree_id, + branch: request.branch, + log_path, + }); + Ok(()) + } + + /// 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, + request: &StartRequest, + tree_path: &Path, + output_dir: Option<&Path>, + arch: Option<&str>, + ) -> Result { + let fs = Arc::clone(&self.fs); + let env = Arc::clone(&self.env); + let history = Arc::clone(&self.history); + let request = request.clone(); + 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); + task::spawn_blocking(move || { + Self::prepare_deploy_blocking( + kind, + &request, + &*fs, + &*env, + &*history, + &tree_path, + output_dir.as_deref(), + arch.as_deref(), + ) + }) + .await + .map_err(|error| KwStartError::GitStateProbe(error.to_string()))? + } + + /// 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) is `None`, not a wrong branch. + async fn checkout_build_branch( + &self, + request: &StartRequest, + ) -> Result, KwStartError> { + let shell = Arc::clone(&self.shell); + let tree_path = request.tree.path().to_string(); + let branch = request.branch.clone(); + 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 { + Some(pre_job_branch) + }) + } + + /// Switches the tree back after a start that reached the branch + /// switch but failed before accepting the job. A rollback failure is + /// logged, not reported: the caller already holds the actionable + /// refusal. An unprobed pre-job HEAD leaves nothing to roll back to. + async fn rollback_switch(&self, tree_path: &str, pre_job_branch: Option<&str>) { + let Some(branch) = pre_job_branch else { + tracing::warn!( + tree = tree_path, + "cannot roll back the branch switch: pre-job HEAD was unprobed" + ); + return; + }; + let shell = Arc::clone(&self.shell); + let tree_path = tree_path.to_string(); + let branch = branch.to_string(); + let branch_for_task = branch.clone(); + match task::spawn_blocking(move || { + KwGitService::switch_to_branch(&*shell, &tree_path, &branch_for_task) + }) + .await + { + Ok(Ok(())) => tracing::info!( + branch, + "rolled back the branch switch after a refused start" + ), + Ok(Err(error)) => { + let error = KwStartError::from(error); + tracing::warn!(%error, "failed to roll back the branch switch") + } + Err(error) => { + tracing::warn!(%error, "branch-switch rollback task failed to join") + } + } + } + + /// Creates the job's log dir and spawns the kw process. Split from + /// `start_job` so a failure here can roll the branch switch back. + fn spawn_build_process( + &self, + request: &StartRequest, + ) -> Result<(Box, PathBuf), KwStartError> { + self.fs.create_dir_all(&self.kw_log_dir)?; + // Millisecond suffix: two jobs started within the same second must + // not share a log file — spawn truncates it. + let log_path = self.kw_log_dir.join(format!( + "build-{}.log", + Utc::now().format("%Y%m%d-%H%M%S-%3f") + )); + 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)) + } + + /// Creates the job's log dir and spawns `kw deploy`. Split from + /// `start_job` so a failure here can roll the branch switch back. + fn spawn_deploy_process( + &self, + tree_path: &str, + deploy: &JobDeploy, + ) -> Result<(Box, PathBuf), KwStartError> { + self.fs.create_dir_all(&self.kw_log_dir)?; + let log_path = self.kw_log_dir.join(format!( + "deploy-{}.log", + Utc::now().format("%Y%m%d-%H%M%S-%3f") + )); + let cmd = ShellCommand::new("kw").args(argv::KwArgvService::build_deploy_argv( + &deploy.remote.endpoint(), + deploy.options.reboot, + deploy.options.force, + &deploy.extra_args, + )); + let cwd = PathBuf::from(tree_path); + let process = self.process.spawn(&cmd, &cwd, &log_path)?; + Ok((process, log_path)) + } + + fn request_cancel(&mut self) -> Result<(), KwError> { + match self.job.as_mut() { + Some(job) => { + if let Some(cancel) = job.cancel_tx.take() { + let _ = cancel.send(()); + } + Ok(()) + } + None => Err(KwError::NoJobRunning), + } + } + + fn handle_job_event(&mut self, event: JobEvent) { + match event { + JobEvent::Finished(outcome) => { + let Some(job) = self.job.take() else { + tracing::warn!("kw job finished with no job state recorded"); + return; + }; + 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, + }; + + // The record is written before the status flips: watchers + // that react to the terminal status find the history + // 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, + } + } 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) => { + tracing::warn!(branch = job.branch, %error, "failed to wait on kw job"); + KwJobStatus::Failed { + kind: job.kind, + phase: job.phase, + exit_code: None, + 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::Cancelled => { + tracing::info!(branch = job.branch, "kw job cancelled"); + KwJobStatus::Cancelled { + kind: job.kind, + phase: job.phase, + log_path: job.log_path, + } + } + }; + + self.set_status(status); + } + } + } + + /// After a successful BuildThenDeploy build, write the build record + /// and spawn the deploy process. `Break` means this Finished was + /// consumed (job still running, or failed at the deploy spawn + /// boundary). `Continue` hands the job back for a normal terminal. + fn advance_build_then_deploy( + &mut self, + job: JobState, + outcome: &JobOutcome, + ) -> ControlFlow<(), JobState> { + let JobOutcome::Exited(exit) = outcome else { + return ControlFlow::Continue(job); + }; + if job.kind != KwJobKind::BuildThenDeploy + || job.phase != KwPhase::Building + || !exit.success() + // A cancel during Building must not deploy, even if the build + // process exited 0 (wait racing the cancel arm, or a trapped + // SIGTERM). `cancel_tx` is taken when Cancel is requested. + || job.cancel_tx.is_none() + { + 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. + let kernelrelease = self.record_build_outcome(&job, outcome); + let Some(mut deploy) = job.deploy.clone() else { + tracing::error!("BuildThenDeploy missing deploy context after a successful build"); + self.set_status(KwJobStatus::Failed { + kind: job.kind, + phase: KwPhase::Deploying, + exit_code: None, + log_path: job.log_path, + first_error: None, + }); + 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(); + spawn(Self::run_job(process, cancel_rx, self.job_event_tx.clone())); + tracing::info!( + kernel_tree_id = job.kernel_tree_id, + branch = job.branch, + log_path = %log_path.display(), + "kw deploy phase started" + ); + self.set_status(KwJobStatus::Running { + kind: job.kind, + phase: KwPhase::Deploying, + kernel_tree_id: job.kernel_tree_id.clone(), + branch: job.branch.clone(), + log_path: log_path.clone(), + }); + let mut job = job; + job.phase = KwPhase::Deploying; + job.log_path = log_path; + job.deploy = Some(deploy); + job.cancel_tx = Some(cancel_tx); + self.job = Some(job); + ControlFlow::Break(()) + } + Err(error) => { + tracing::warn!( + %error, + branch = job.branch, + "failed to spawn kw deploy after a successful build" + ); + self.set_status(KwJobStatus::Failed { + kind: job.kind, + phase: KwPhase::Deploying, + exit_code: None, + log_path: job.log_path, + first_error: None, + }); + ControlFlow::Break(()) + } + } + } + + /// Persist a finished build (success or failure). Cancel writes + /// nothing. History and patchset-link errors are logged, not folded + /// into job status — the build's real outcome already reached the user. + /// Returns the recorded kernelrelease, if the build produced one. + fn record_build_outcome(&self, job: &JobState, outcome: &JobOutcome) -> Option { + if job.kind == KwJobKind::Deploy || job.phase == KwPhase::Deploying { + return None; + } + let success = match outcome { + JobOutcome::Exited(exit) => exit.success(), + // The exit status is lost: record an honest failure rather + // than guess, so deploy-alone readiness cannot trust it. + JobOutcome::WaitFailed(_) => false, + JobOutcome::Cancelled => return None, + }; + + // 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 + // successful one behind; only successes record what they + // produced. + let (image_path, kernelrelease) = if success { + ( + 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) + }; + + let message_id = match self + .history + .apply_record_for_branch(&job.kernel_tree_id, &job.branch) + { + Ok(record) => record.map(|record| record.message_id), + Err(error) => { + tracing::warn!( + %error, + branch = job.branch, + "build record loses its patchset link" + ); + None + } + }; + + let record = KwBuildRecord { + kernel_tree_id: job.kernel_tree_id.clone(), + tree_path: job.tree_path.clone(), + message_id, + branch: job.branch.clone(), + arch: job.arch.clone(), + image_path: image_path.map(|path| path.to_string_lossy().into_owned()), + output_dir: job + .output_dir + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + kernelrelease: kernelrelease.clone(), + log_path: job.log_path.to_string_lossy().into_owned(), + 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"); + } + kernelrelease + } + + /// Reads a finished job's log for the findings its terminal status + /// carries. An unreadable log only loses those findings: the exit code + /// is still the verdict. + fn read_job_log(&self, log_path: &Path) -> Option { + match self.fs.read_to_string(log_path) { + Ok(log) => Some(log), + Err(error) => { + tracing::warn!(%error, log_path = %log_path.display(), "failed to read kw job log"); + None + } + } + } + + fn set_status(&mut self, job: KwJobStatus) { + // send_replace, not send: no receiver (nobody called WatchStatus + // yet) is a normal state, not an error. Restore availability is + // published with every snapshot so the UI does not have to guess + // whether RestorePreviousBranch would succeed. + self.status_tx.send_replace(KwStatusSnapshot { + job, + restore_branch: self + .last_restore + .as_ref() + .map(|restore| restore.branch.clone()), + }); + } + + /// Re-broadcasts the current job with the current restore projection. + /// Used after restore succeeds (the branch is gone) without changing + /// the job's own status. + fn republish_status(&mut self) { + let job = self.status_tx.borrow().job.clone(); + self.set_status(job); + } + + /// Git probes and history I/O run on the blocking pool so a slow + /// tree cannot stall Cancel (or any other message) for the duration. + async fn evaluate_readiness( + &self, + kernel_tree_id: &str, + tree: &KernelTree, + for_branch: Option, + ) -> Result { + let fs = Arc::clone(&self.fs); + let env = Arc::clone(&self.env); + let shell = Arc::clone(&self.shell); + let history = Arc::clone(&self.history); + let kernel_tree_id = kernel_tree_id.to_string(); + let tree = tree.clone(); + task::spawn_blocking(move || { + let head = KwGitService::probe_head_branch(&*shell, tree.path()); + readiness::ReadinessService::evaluate_readiness( + &*fs, + &*env, + &*shell, + &*history, + &kernel_tree_id, + &tree, + &head, + for_branch.as_deref(), + ) + .map_err(KwError::from) + }) + .await + .map_err(|error| KwError::GitStateProbe(error.to_string()))? + } + + /// Switches the tree that ran the last job back to the branch HEAD was + /// on when that job was accepted. Refuses while a job is + /// running (its branch is in use), when nothing was recorded, and on + /// a dirty worktree. Only a successful switch consumes the context — + /// a refused restore stays available for a retry. + async fn restore_previous_branch(&mut self) -> Result<(), KwError> { + if self.job.is_some() { + return Err(KwError::JobRunning); + } + let Some(restore) = self.last_restore.take() else { + return Err(KwError::NoRecordedBranch); + }; + let shell = Arc::clone(&self.shell); + let tree_path = restore.tree_path.clone(); + let branch = restore.branch.clone(); + // Same spawn_blocking rationale as the start path's checkout: the + // switch rewrites the worktree. + let result = task::spawn_blocking(move || -> Result<(), TreeGitError> { + KwGitService::check_worktree_clean(&*shell, &tree_path)?; + KwGitService::switch_to_branch(&*shell, &tree_path, &branch) + }) + .await + .map_err(|error| KwError::GitStateProbe(error.to_string())) + .and_then(|result| result.map_err(KwError::from)); + match result { + Ok(()) => { + tracing::info!( + tree = restore.tree_path, + branch = restore.branch, + "restored pre-job branch" + ); + self.republish_status(); + Ok(()) + } + Err(error) => { + self.last_restore = Some(restore); + Err(error) + } + } + } +} + +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); + 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 { + remote, + options, + extra_args: request.extra_args.clone(), + kernelrelease, + }) + } + + /// 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(); + } + + /// 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 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 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 + } + } + } + } + } + + /// 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 { + 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 + && build_exited_successfully + { + JobOutcome::Cancelled + } else { + outcome + } + } + + /// 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; + + 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), + } + } +} + +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(()) + } + + /// 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(()) + } + + /// 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() + } 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"); + String::new() + } + } + } +} + +#[cfg(test)] +mod actor_test; +#[cfg(test)] +mod deploy_test; diff --git a/src/kw/argv.rs b/src/kw/argv.rs index 6b398f94..b64a307c 100644 --- a/src/kw/argv.rs +++ b/src/kw/argv.rs @@ -1,8 +1,7 @@ //! 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))] +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. @@ -40,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), @@ -66,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), @@ -108,209 +94,226 @@ 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`. -#[cfg_attr(not(unix), allow(dead_code))] -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) + } + + /// 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, + 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 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 values (`-Fpkg`). A mixed short cluster + /// containing any reserved flag is dropped whole, not rewritten. + pub fn merge_extra_args( + base: &[&str], + reserved: &[ReservedOption], + extra_args: &[String], + ) -> Vec { + 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) { + 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 } - 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) -} +impl KwArgvService { + /// 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, + ) -> Option<&'a ReservedOption> { + if token.starts_with("--") { + return Self::find_reserved_long_option(reserved, token); + } + Self::find_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 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, + ) -> 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 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 = 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)] 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() { - 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"])) ); } @@ -318,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"])) ); } @@ -331,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"])) ); } @@ -344,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] @@ -359,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"])) ); } @@ -367,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", @@ -377,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] @@ -386,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"])) ); } @@ -399,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"]) @@ -407,17 +416,11 @@ mod tests { ); } - const ENDPOINT: &str = "root@lima-ph-dut.internal:22"; - - fn deploy(extra: &[&str]) -> Vec { - 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::build_deploy_argv(ENDPOINT, false, true, &[]) ); } @@ -425,7 +428,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::build_deploy_argv(ENDPOINT, true, false, &[]) ); } @@ -675,7 +678,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"])) ); } @@ -685,7 +688,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/errors.rs b/src/kw/errors.rs index cfedc713..5ab63b94 100644 --- a/src/kw/errors.rs +++ b/src/kw/errors.rs @@ -1,13 +1,9 @@ -// The actor that constructs/reads these is unix-only. -#![cfg_attr(not(unix), allow(dead_code))] - use thiserror::Error; -#[cfg(unix)] use crate::infrastructure::process::ProcessError; use crate::{ infrastructure::{file_system::FileSystemError, shell::ShellError}, - kw::{ + kw::models::{ readiness::{DeployAloneRefusal, KwReadinessError, TreeReadiness}, remote::RemoteRefusal, }, @@ -66,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}")] diff --git a/src/kw/handle.rs b/src/kw/handle.rs index ad6ed040..c4bd4515 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, }, }; @@ -16,7 +15,6 @@ pub struct KwHandle { tx: mpsc::Sender, } -#[allow(dead_code)] impl KwHandle { pub fn new(tx: mpsc::Sender) -> Self { Self { tx } @@ -87,7 +85,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 85d35c50..c0849ab4 100644 --- a/src/kw/history.rs +++ b/src/kw/history.rs @@ -4,57 +4,19 @@ //! Apply and build records are user state — not a cache — and are never //! refreshed from lore. +use chrono::DateTime; use mockall::automock; -use serde::{Deserialize, Serialize}; +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, KwBuildRecord}; pub const APPLY_HISTORY_FILENAME: &str = "kw_apply_history.json"; 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>; @@ -70,24 +32,11 @@ 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 /// 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))] fn apply_record_for_branch( &self, kernel_tree_id: &str, @@ -96,29 +45,8 @@ 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))] 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. @@ -136,8 +64,8 @@ pub struct FileKwHistoryStore { } 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` and + /// `kw_build_history.json` under `data_dir`. pub fn new(fs: Arc, data_dir: String) -> Self { FileKwHistoryStore { fs, @@ -145,13 +73,15 @@ 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. 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) { @@ -194,21 +124,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, @@ -220,9 +135,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)) @@ -233,24 +146,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, @@ -265,9 +160,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) @@ -278,73 +171,90 @@ 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::*; - - 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!( - "patch-hub-kw-history-{}-{}-{}", - test_name, - std::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).unwrap(); - dir - } + mod helpers { + use super::super::*; + use crate::infrastructure::file_system::OsFileSystem; + use std::{ + env, fs, + path::PathBuf, + process, + 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().unwrap().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() { @@ -353,21 +263,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("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-2026-08-01-17-30-00") + .expect("apply record loads") ); 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") + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -377,17 +291,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("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-new") + .expect("apply record loads") + ); + assert_eq!( + None, + store + .apply_record_for_branch("mainline", "patchset-old") + .expect("apply record loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -397,22 +319,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("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-mainline") + .expect("apply record loads") ); assert_eq!( Some(record("msg-1", "stable", "patchset-stable")), - store.apply_record("msg-1", "stable").unwrap() + store + .apply_record_for_branch("stable", "patchset-stable") + .expect("apply record loads") + ); + assert_eq!( + None, + store + .apply_record_for_branch("amd-gfx", "patchset-mainline") + .expect("apply record loads") ); - assert_eq!(None, store.apply_record("msg-1", "amd-gfx").unwrap()); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -420,9 +351,14 @@ 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") + .expect("apply record loads") + ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -432,25 +368,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] @@ -461,25 +397,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] @@ -497,7 +433,7 @@ mod tests { "patchset-x", "2026-08-01T10:00:00Z", )) - .unwrap(); + .expect("apply records"); store .record_apply(record_at( "msg-new", @@ -505,7 +441,7 @@ mod tests { "patchset-x", "2026-08-02T10:00:00Z", )) - .unwrap(); + .expect("apply records"); store .record_apply(record_at( "msg-broken", @@ -513,7 +449,7 @@ mod tests { "patchset-x", "not a timestamp", )) - .unwrap(); + .expect("apply records"); assert_eq!( Some(record_at( @@ -524,10 +460,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] @@ -539,10 +475,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] @@ -553,29 +489,33 @@ 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("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-x") + .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("msg-1", "mainline").unwrap_err(); + let err = store + .apply_record_for_branch("mainline", "patchset-x") + .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 @@ -584,10 +524,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] @@ -597,15 +537,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] @@ -615,26 +555,38 @@ 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. + // refuses it. The newer failed record is also the tree's latest. + assert_eq!( + ( + Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), + Some(failed.clone()), + ), + store + .build_records("mainline", "for-next") + .expect("build history loads") + ); assert_eq!( - Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), - store.build_record("mainline", "for-next").unwrap() + (Some(failed.clone()), Some(failed.clone())), + store + .build_records("mainline", "patchset-x") + .expect("build history loads") ); assert_eq!( - Some(failed), - store.build_record("mainline", "patchset-x").unwrap() + (None, Some(failed)), + store + .build_records("mainline", "master") + .expect("build history loads") ); - assert_eq!(None, store.build_record("mainline", "master").unwrap()); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -644,17 +596,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(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") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -664,56 +619,84 @@ 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!( - 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") + .expect("build history loads") ); 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") + .expect("build history loads") ); + 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") + .expect("build history loads") ); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[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); 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!( + ( + Some(build("mainline", "for-next", "2026-08-01T18:10:00Z")), + Some(newest.clone()), + ), + store + .build_records("mainline", "for-next") + .expect("build history loads") + ); 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") + .expect("build history loads") + ); + assert_eq!( + (None, None), + store + .build_records("amd-gfx", "patchset-x") + .expect("build history loads") ); - assert_eq!(None, store.latest_build_record("amd-gfx").unwrap()); - fs::remove_dir_all(&dir).unwrap(); + fs::remove_dir_all(&dir).expect("temp dir removes"); } #[test] @@ -723,17 +706,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!( @@ -741,14 +726,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] @@ -756,29 +745,35 @@ 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") + .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_record("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")) .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] @@ -788,15 +783,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] @@ -806,20 +801,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, store.build_record("mainline", "patchset-x").unwrap()); + assert_eq!( + (None, None), + 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("msg-1", "mainline").unwrap() + store + .apply_record_for_branch("mainline", "patchset-x") + .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 bc2fc69a..c0a66a1b 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; @@ -22,161 +19,173 @@ 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 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) + .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 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 + .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()) -} +impl LogScanService { + 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(), + } } } #[cfg(test)] mod tests { - use super::*; - /// `kw build` under an env `O=` with in-tree leftovers in the source - /// tree (lab/logs/20260920-122005). - 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 { + + /// `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' + "; - /// The M4 fixture's compile break (lab/logs/20260920-151635/M18). - 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 + /// 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 to the Ubuntu DUT: kw exits 0 although - /// initramfs generation failed and GRUB never listed the kernel - /// (lab/logs/20261003-111949). - 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 + "; - /// The real-boot deploy of the tipc patch: everything worked - /// (lab/logs/20261003-154745-real-boot-tui-tipc). - 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() { @@ -185,7 +194,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) ); } @@ -196,7 +205,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) ); } @@ -204,17 +213,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" + ) ); } @@ -228,20 +241,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).expect("first error finds"); assert_eq!(MAX_LINE_CHARS + 1, found.chars().count()); assert!(found.ends_with('…')); } @@ -253,7 +269,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::collect_deploy_warnings(TINYCONFIG_DEPLOY_LOG, Some("7.2.0-rc6+")) ); } @@ -261,15 +277,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::collect_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::collect_deploy_warnings( + CLEAN_DEPLOY_LOG, + Some("7.2.0-rc6-phboot-g2a475abe5df2") + ) + .is_empty()); } #[test] @@ -282,14 +300,18 @@ done "; assert_eq!( vec!["GRUB did not list kernel 7.2.0-rc6".to_string()], - 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!(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] @@ -304,14 +326,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::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 = 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/messages.rs b/src/kw/messages.rs index efc31d0c..25d66903 100644 --- a/src/kw/messages.rs +++ b/src/kw/messages.rs @@ -1,14 +1,10 @@ -// The actor that reads these fields is unix-only. -#![cfg_attr(not(unix), allow(dead_code))] - use tokio::sync::{oneshot, watch}; 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 f6b21767..457a737d 100644 --- a/src/kw/mod.rs +++ b/src/kw/mod.rs @@ -1,7 +1,6 @@ //! 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; @@ -9,6 +8,7 @@ pub mod handle; pub mod history; pub mod log_scan; pub mod messages; +pub(crate) 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 00000000..26340739 --- /dev/null +++ b/src/kw/models/history.rs @@ -0,0 +1,38 @@ +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` 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, + /// 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 00000000..d9ca770e --- /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 00000000..3ead4c33 --- /dev/null +++ b/src/kw/models/mod.rs @@ -0,0 +1,4 @@ +pub mod history; +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 00000000..32d7cf1b --- /dev/null +++ b/src/kw/models/readiness.rs @@ -0,0 +1,155 @@ +use thiserror::Error; + +use std::{fmt, 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. +#[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 [`ReadinessService::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, +} + +/// 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, + 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 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"), + 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. +#[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 deploy config sets `boot_into_new_kernel_once=no`. +/// +/// 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, + On, + Unknown, +} + +/// Snapshot of tree, kw binary, and history probes that 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 00000000..b4ee1a67 --- /dev/null +++ b/src/kw/models/remote.rs @@ -0,0 +1,39 @@ +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. +#[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. +#[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 d4121809..48dcf65f 100644 --- a/src/kw/readiness.rs +++ b/src/kw/readiness.rs @@ -1,18 +1,10 @@ -//! 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 -//! [`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 _}; -use thiserror::Error; use std::{ collections::HashMap, @@ -20,764 +12,612 @@ use std::{ time::SystemTime, }; -use crate::infrastructure::{ - env::{EnvError, EnvTrait}, - file_system::{FileSystemError, FileSystemTrait}, - shell::{ShellCommand, ShellTrait}, -}; use crate::{ config::KernelTree, + infrastructure::{ + env::EnvTrait, + file_system::{FileSystemError, FileSystemTrait}, + shell::{ShellCommand, ShellTrait}, + }, kw::{ - history::{KwBuildRecord, KwHistoryStore}, - remote::{self, KwRemote, RemoteRefusal}, + history::KwHistoryStore, + models::{ + history::KwBuildRecord, + readiness::{ + BootOnceState, DeployAloneRefusal, KwBinaryProbe, KwReadiness, KwReadinessError, + KwVersionCheck, TreeReadiness, + }, + }, + remote, }, }; -/// 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), -} +pub struct ReadinessService; -/// Readiness of a configured kernel tree for kw operations. -#[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, -} +/// Minimum kw version this integration is verified against. +pub const KW_MIN_VERSION: (u32, u32) = (0, 10); -/// 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; +impl ReadinessService { + /// 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 uncommented = match line.rfind('#') { - Some(index) => &line[..index], - None => line, + 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, }; - 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()); + KwBinaryProbe { + available: true, + version_line, + check, + } } - 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 !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), + /// 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() { + 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 = 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 -/// [`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 = parse_kw_config(&content).remove("arch")?; - if arch.is_empty() { - None - } else { - Some(arch) + /// 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")) + .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") { + /// 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, + 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); + } + // 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) => 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| { - newest_image_in(fs, &arch_dir.join("boot")) - .map(|image| (image_mtime(fs, &image), image)) - }) - .max_by_key(|(mtime, _)| *mtime) - .map(|(_, image)| image), - } -} - -/// Newest `*Image` file directly inside `boot_dir`, if any. -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) -} - -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). -// The only production caller is the unix-only actor's build-record -// writer. -#[cfg_attr(not(unix), allow(dead_code))] -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); - -/// 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). -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 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('/') { + "" => "/", + path => path, + }; + let encoded = BASE64.encode(normalized); + Ok(Some( + Path::new(&cache_base) + .join("kw") + .join("envs") + .join(encoded) + .join(env_name), + )) + } + + /// 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, + 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), + } } - 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, + /// 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()) + } } -} - -/// 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()?)) -} -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=)" - ) + /// 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) => {} } - 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)" - ), + } + 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, } } -} - -/// Why a deploy-without-build was refused. Each variant's message is the -/// actionable explanation. -#[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. -#[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 -/// 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 boot_once_from_file(fs, &local) { - Err(()) => return BootOnceState::Unknown, - Ok(Some(state)) => return state, - Ok(None) => {} + /// 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>, + 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(()) + } + + /// 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, + 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), + }) } - 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, - } -} - -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. -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. -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(), - }); +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 + } else { + KwVersionCheck::Below(version_line.to_string()) } } - return Err(DeployAloneRefusal::NoBuildRecord); + None => KwVersionCheck::Unknown, } - }; - 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(()) -} + } -/// 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>, - /// 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, -} + /// 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 = parts + .next()? + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect::(); + Some((major, minor.parse().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. -// The only caller is the unix-only actor's GetReadiness. -#[cfg_attr(not(unix), allow(dead_code))] -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, - kernel_image, - build_record, - latest_build, - deploy_alone, - current_branch: { - let trimmed = head_branch.trim(); - if trimmed.is_empty() { - None + /// 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 = if let Ok(xdg) = env.var("XDG_CONFIG_HOME") { + if xdg.is_empty() { + format!("{}/.config", env.var("HOME").ok()?) } else { - Some(trimmed.to_string()) + xdg } - }, - deploy_remote: remote::resolve_deploy_remote(fs, env, tree_path), - boot_once: probe_boot_once(fs, env, tree_path), - }) + } else { + format!("{}/.config", env.var("HOME").ok()?) + }; + Some(Path::new(&config_home).join("kw").join(filename)) + } } #[cfg(test)] mod tests { - use std::{ - fs, - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, - time::{Duration, SystemTime}, - }; - use crate::infrastructure::{ - env::MockEnvTrait, - file_system::{MockFileSystemTrait, OsFileSystem}, - shell::{MockShellTrait, ShellOutput}, - }; - use crate::kw::history::FileKwHistoryStore; + mod helpers { + use std::{ + fs, + path::Path, + time::{Duration, SystemTime}, + }; - use super::*; + use crate::config::KernelTree; + use crate::infrastructure::shell::ShellOutput; + + use crate::kw::models::history::KwBuildRecord; + + use crate::test_support::TempDir; + + 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"); + } + } - 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 = std::env::temp_dir().join(format!( - "patch-hub-kw-readiness-{}-{}-{}", - test_name, - std::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).unwrap(); - Self(dir) + /// 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 } - fn path(&self) -> &Path { - &self.0 + /// 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"); } - } - impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); + pub(super) fn shell_output(stdout: &str, success: bool) -> ShellOutput { + ShellOutput { + stdout: stdout.as_bytes().to_vec(), + stderr: Vec::new(), + success, + } } - } - 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), "").unwrap(); + 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") } - for sub in KERNEL_ROOT_DIRS { - fs::create_dir(dir.join(sub)).unwrap(); + + 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 crate::test_support::TempDir; + use helpers::*; + use std::{ + env, fs, io, + path::{Path, PathBuf}, + sync::Arc, + }; - /// 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")).unwrap(); - fs::write(dir.path().join(".config"), "").unwrap(); - dir - } + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + + use crate::infrastructure::{ + env::MockEnvTrait, + file_system::{FileSystemError, MockFileSystemTrait, OsFileSystem}, + shell::MockShellTrait, + }; + use crate::kw::history::{FileKwHistoryStore, KwHistoryStore}; + use crate::kw::models::readiness::{BootOnceState, DeployAloneRefusal, TreeReadiness}; + use crate::kw::models::remote::RemoteRefusal; + + use super::*; #[test] fn parse_kw_config_mirrors_kw_semantics() { @@ -793,7 +633,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())); @@ -817,7 +657,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] @@ -827,10 +667,10 @@ 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!( - !is_kernel_root(&OsFileSystem, dir.path()), + !ReadinessService::is_kernel_root(&OsFileSystem, dir.path()), "missing {member} should fail the check" ); } @@ -842,7 +682,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) ); } @@ -852,7 +692,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::NotAKernelRoot, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -863,7 +703,7 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::MissingKwDir, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -871,11 +711,11 @@ 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, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -885,20 +725,20 @@ last_line_without_newline=yes"; assert_eq!( TreeReadiness::Ready { arch: None }, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } #[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 { arch: Some("arm64".to_string()) }, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } @@ -907,16 +747,16 @@ 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, - probe_tree(&OsFileSystem, dir.path(), Some(out.path())) + 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!( - probe_tree(&OsFileSystem, dir.path(), Some(out.path())), + ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())), TreeReadiness::Ready { .. } )); } @@ -925,47 +765,47 @@ 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, - probe_tree(&OsFileSystem, dir.path(), Some(out.path())) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())) ); } #[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, - probe_tree(&OsFileSystem, dir.path(), Some(out.path())) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), Some(out.path())) ); } #[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 }, - probe_tree(&OsFileSystem, dir.path(), None) + ReadinessService::probe_tree(&OsFileSystem, dir.path(), None) ); } #[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()), - read_build_arch(&OsFileSystem, dir.path()) + ReadinessService::read_build_arch(&OsFileSystem, dir.path()) ); } @@ -974,34 +814,35 @@ 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( no_key.path().join(".kw/build.config"), "cpu_scaling_factor=100\n", ) - .unwrap(); - assert_eq!(None, read_build_arch(&OsFileSystem, no_key.path())); + .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(); - assert_eq!(None, read_build_arch(&OsFileSystem, commented.path())); + 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(); - assert_eq!(None, read_build_arch(&OsFileSystem, empty.path())); - } - - /// 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(); - file.set_times( - fs::FileTimes::new() - .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs)), - ) - .unwrap(); + fs::write(empty.path().join(".kw/build.config"), "arch=\n").expect("file writes"); + assert_eq!( + None, + ReadinessService::read_build_arch(&OsFileSystem, empty.path()) + ); } #[test] @@ -1011,7 +852,8 @@ last_line_without_newline=yes"; assert_eq!( None, - resolve_output_dir(&OsFileSystem, &env, dir.path()).unwrap() + ReadinessService::resolve_output_dir(&OsFileSystem, &env, dir.path()) + .expect("output dir resolves") ); } @@ -1020,15 +862,22 @@ 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 = resolve_output_dir(&fs, &env, Path::new("/home/user/linux")).unwrap(); + let resolved = + 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")), @@ -1039,18 +888,26 @@ 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") - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .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 = 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( @@ -1067,18 +924,26 @@ 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 = 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( @@ -1095,15 +960,23 @@ 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 = resolve_output_dir(&fs, &env, Path::new("/home/user/linux/")).unwrap(); + let resolved = + 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")), @@ -1114,47 +987,64 @@ 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(); assert_eq!( None, - resolve_output_dir(&fs, &env, Path::new("/kernel")).unwrap() + ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")) + .expect("output dir resolves") ); } #[test] 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_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(); - assert!(resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); + assert!(ReadinessService::resolve_output_dir(&fs, &env, Path::new("/kernel")).is_err()); } #[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() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .withf(|key| matches!(key, "HOME" | "XDG_CACHE_HOME")) + .times(2) + .returning(|_| Err(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] 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`. @@ -1163,7 +1053,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")) ); } @@ -1174,14 +1064,14 @@ 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); assert_eq!( Some(arm64_boot.join("Image")), - find_newest_kernel_image(&OsFileSystem, dir.path(), None) + ReadinessService::find_newest_kernel_image(&OsFileSystem, dir.path(), None) ); } @@ -1191,13 +1081,13 @@ 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); 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")) ); } @@ -1207,25 +1097,25 @@ 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"); - 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 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")) ); } @@ -1233,12 +1123,12 @@ 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()), - read_kernelrelease(&OsFileSystem, dir.path()) + ReadinessService::read_kernelrelease(&OsFileSystem, dir.path()) ); } @@ -1246,44 +1136,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())); - } - - 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().unwrap(), - "branch": "master" - })) - .unwrap() - } - - fn built_record(tree: &Path, branch: &str) -> KwBuildRecord { - KwBuildRecord { - kernel_tree_id: "mainline".to_string(), - tree_path: tree.to_str().unwrap().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, - } + 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()) + ); } #[test] @@ -1291,11 +1155,12 @@ 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 = probe_kw_binary(&env, &shell); + let probe = ReadinessService::probe_kw_binary(&env, &shell); assert!(!probe.available); assert_eq!(None, probe.version_line); @@ -1317,15 +1182,24 @@ 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"]) - .returning(move |_| Ok(shell_output(str::from_utf8(&stdout_bytes).unwrap(), true))); + .times(1) + .returning(move |_| { + Ok(shell_output( + str::from_utf8(&stdout_bytes).expect("bytes are utf-8"), + 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:?}"); @@ -1335,16 +1209,23 @@ 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 = probe_kw_binary(&env, &shell); + let probe = ReadinessService::probe_kw_binary(&env, &shell); assert_eq!(Some("beta-0.9".to_string()), probe.version_line); } @@ -1355,23 +1236,32 @@ last_line_without_newline=yes"; let mut spawn_fails = MockShellTrait::new(); spawn_fails .expect_execute() - .returning(|_| Err(std::io::Error::other("spawn failed").into())); + .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 = probe_kw_binary(&env, &shell); + let probe = ReadinessService::probe_kw_binary(&env, &shell); assert!(probe.available); assert_eq!(None, probe.version_line); @@ -1387,14 +1277,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) + ) ); } @@ -1412,7 +1316,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 @@ -1422,17 +1333,24 @@ 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. 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(), }), - check_deploy_alone( + ReadinessService::check_deploy_alone( Some(&record), None, &moved_tree, @@ -1445,7 +1363,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, @@ -1458,20 +1376,34 @@ 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. 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(()), - check_deploy_alone( + ReadinessService::check_deploy_alone( Some(&slashed), None, &tree, @@ -1491,45 +1423,51 @@ 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) ); } #[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(), ); - let record = built_record(dir.path(), "patchset-x"); - history.record_build(record.clone()).unwrap(); + history + .record_build(built_record(dir.path(), "patchset-x")) + .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()); - let readiness = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1539,7 +1477,7 @@ last_line_without_newline=yes"; "patchset-x", None, ) - .unwrap(); + .expect("readiness evaluates"); assert_eq!( TreeReadiness::Ready { @@ -1547,15 +1485,20 @@ 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); 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); } @@ -1566,18 +1509,23 @@ 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(); - env.expect_which().returning(|_| false); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .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 = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1587,12 +1535,9 @@ last_line_without_newline=yes"; "patchset-x", None, ) - .unwrap(); + .expect("readiness evaluates"); 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!( @@ -1612,18 +1557,23 @@ 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(); - env.expect_which().returning(|_| false); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .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 = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1633,13 +1583,10 @@ 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); - 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 @@ -1655,16 +1602,21 @@ 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); + env.expect_which() + .withf(|name| name == "kw") + .times(1) + .returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .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 = evaluate_readiness( + let readiness = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1674,7 +1626,7 @@ last_line_without_newline=yes"; "", None, ) - .unwrap(); + .expect("readiness evaluates"); assert_eq!(None, readiness.current_branch); } @@ -1684,24 +1636,30 @@ 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(), ); - let record = built_record(dir.path(), "patchset-x"); - history.record_build(record.clone()).unwrap(); + history + .record_build(built_record(dir.path(), "patchset-x")) + .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); + env.expect_which() + .withf(|name| name == "kw") + .times(2) + .returning(|_| false); env.expect_var() - .returning(|_| Err(std::env::VarError::NotPresent.into())); + .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 = evaluate_readiness( + let on_head = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1711,9 +1669,8 @@ last_line_without_newline=yes"; "master", None, ) - .unwrap(); + .expect("readiness evaluates"); 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(), @@ -1722,7 +1679,7 @@ last_line_without_newline=yes"; on_head.deploy_alone ); - let for_typed = evaluate_readiness( + let for_typed = ReadinessService::evaluate_readiness( &OsFileSystem, &env, &shell, @@ -1732,10 +1689,9 @@ 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!(Some(record), for_typed.build_record); assert_eq!(Ok(()), for_typed.deploy_alone); } @@ -1746,11 +1702,11 @@ 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, - probe_boot_once(&OsFileSystem, &env, off.path()) + ReadinessService::probe_boot_once(&OsFileSystem, &env, off.path()) ); let on = make_ready_tree("boot-once-on"); @@ -1758,10 +1714,10 @@ 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, - probe_boot_once(&OsFileSystem, &env, on.path()) + ReadinessService::probe_boot_once(&OsFileSystem, &env, on.path()) ); } @@ -1770,10 +1726,12 @@ 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())); + .withf(|key| matches!(key, "HOME" | "XDG_CONFIG_HOME")) + .times(2) + .returning(|_| Err(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"); @@ -1781,34 +1739,47 @@ 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, - probe_boot_once(&OsFileSystem, &MockEnvTrait::new(), weird.path()) + ReadinessService::probe_boot_once(&OsFileSystem, &MockEnvTrait::new(), weird.path()) ); } #[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!( BootOnceState::Off, - probe_boot_once(&fs, &env, Path::new("/kernel")) + ReadinessService::probe_boot_once(&fs, &env, Path::new("/kernel")) ); } @@ -1816,18 +1787,23 @@ 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(std::io::Error::new( - std::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!( BootOnceState::Unknown, - probe_boot_once(&fs, &env, Path::new("/kernel")) + ReadinessService::probe_boot_once(&fs, &env, Path::new("/kernel")) ); } @@ -1835,20 +1811,29 @@ 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!( BootOnceState::Off, - probe_boot_once(&fs, &env, Path::new("/kernel")) + ReadinessService::probe_boot_once(&fs, &env, Path::new("/kernel")) ); } } diff --git a/src/kw/remote.rs b/src/kw/remote.rs index 24a0c220..4c63e6dd 100644 --- a/src/kw/remote.rs +++ b/src/kw/remote.rs @@ -1,195 +1,14 @@ -//! 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. -//! -//! Production callers are readiness probes and the unix-only deploy start -//! path. - -#![cfg_attr(not(unix), allow(dead_code))] +//! 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}; -use thiserror::Error; - 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, -} - -impl KwRemote { - /// The `--remote` token kw's deploy parser accepts: - /// `[user@]host:port`. - pub fn endpoint(&self) -> String { - match &self.user { - Some(user) => format!("{}@{}:{}", user, self.hostname, self.port), - None => format!("{}:{}", self.hostname, self.port), - } - } -} - -/// Why a deploy remote could not be resolved. Each variant's message is -/// the actionable explanation. -#[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. -#[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`, -/// `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)) = 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. -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 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) -} - -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. -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")) -} +use crate::kw::models::remote::{KwRemote, ParsedRemoteConfig, RemoteRefusal}; struct HostBuilder { name: String, @@ -224,74 +43,285 @@ impl HostBuilder { } } -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; +pub struct RemoteConfigService; + +impl RemoteConfigService { + /// 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; + + 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` + /// 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 = 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")) + } + + 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); + } } - 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: + /// `[user@]host:port`. + pub fn endpoint(&self) -> String { + match &self.user { + Some(user) => format!("{}@{}:{}", user, self.hostname, self.port), + None => format!("{}:{}", self.hostname, self.port), + } } } #[cfg(test)] mod tests { - use std::{collections::HashMap, path::PathBuf, sync::Arc}; - - use crate::infrastructure::{ - env::{EnvError, MockEnvTrait}, - file_system::{FileSystemError, MockFileSystemTrait}, - }; - use super::*; + mod helpers { + use super::super::*; + use crate::infrastructure::{ + env::{EnvError, MockEnvTrait}, + file_system::{FileSystemError, MockFileSystemTrait}, + }; + use crate::kw::models::remote::RemoteRefusal; + 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 = "\ + #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 { - select_deploy_remote(&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::{io, path::Path}; + + use crate::infrastructure::file_system::{FileSystemError, MockFileSystemTrait}; + + use super::*; + use crate::kw::models::remote::RemoteRefusal; #[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![ @@ -302,13 +332,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 @@ -323,7 +355,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()); } @@ -334,7 +366,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()); @@ -377,8 +409,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 @@ -391,7 +423,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); } @@ -405,7 +437,7 @@ Host dut "; assert_eq!( remote("dut", "Box", 2222, Some("Root")), - choose(content).unwrap() + choose(content).expect("candidate resolves") ); } @@ -420,7 +452,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); } @@ -471,7 +503,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") ); } @@ -482,7 +514,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] @@ -500,43 +532,6 @@ Host dut extra .contains("gone")); } - fn fs_with_files(files: &[(&str, &str)]) -> MockFileSystemTrait { - let map: HashMap = 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() - .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", - )) - }) - }); - 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().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 { - std::env::VarError::NotPresent.into() - } - #[test] fn resolve_prefers_the_tree_remote_config() { let fs = fs_with_files(&[ @@ -545,7 +540,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")) + .expect("deploy remote resolves"); assert_eq!("ph-dut", chosen.name); } @@ -557,7 +554,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")) + .expect("deploy remote resolves"); assert_eq!("root@box:22", chosen.endpoint()); } @@ -569,7 +568,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")) + .expect("deploy remote resolves"); assert_eq!("box:22", chosen.endpoint()); } @@ -580,7 +580,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 +596,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")) ); } @@ -604,18 +604,23 @@ 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(std::io::Error::new( - std::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!( Err(RemoteRefusal::NoRemotesConfigured), - resolve_deploy_remote(&fs, &env, Path::new("/kernel")) + RemoteConfigService::resolve_deploy_remote(&fs, &env, Path::new("/kernel")) ); } @@ -626,7 +631,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 26eab61f..d52dc353 100644 --- a/src/kw/status.rs +++ b/src/kw/status.rs @@ -3,10 +3,7 @@ //! `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; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KwJobKind { @@ -38,7 +35,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 + /// [`LogScanService::collect_deploy_warnings`]). Always empty for a /// build-only job. warnings: Vec, }, @@ -85,14 +82,17 @@ impl KwStatusSnapshot { }; Some(format!("kw: {phase} {branch}")) } - _ => None, + KwJobStatus::Idle + | KwJobStatus::Succeeded { .. } + | KwJobStatus::Failed { .. } + | KwJobStatus::Cancelled { .. } => None, } } } 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, .. } @@ -101,44 +101,49 @@ 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. -#[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", - 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)] 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() { @@ -207,15 +212,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/lore/application/actor.rs b/src/lore/application/actor.rs index c1101713..619de516 100644 --- a/src/lore/application/actor.rs +++ b/src/lore/application/actor.rs @@ -1,24 +1,22 @@ -//! 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`](crate::lore::application::handle::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, 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, }; 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, @@ -51,7 +49,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"); @@ -63,7 +63,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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::FetchAvailableLists { cache_mode, reply } => { @@ -72,7 +78,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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::FetchFeedPage { @@ -95,7 +107,13 @@ impl LoreApiActor { }) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::FetchPatchsetDetails { @@ -114,7 +132,13 @@ impl LoreApiActor { }) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::SaveBookmarks { bookmarks, reply } => { @@ -123,7 +147,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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::SaveReviewed { reviewed, reply } => { @@ -132,7 +162,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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::GetGitSignature { @@ -143,7 +179,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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::PrepareReplyCommands { @@ -174,7 +216,13 @@ impl LoreApiActor { }) .await .and_then(|result| result); - send_lore_reply(message_name, reply, result); + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } LoreApiMessage::Shutdown => { @@ -204,93 +252,83 @@ 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}; - use crate::{ - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - lore::{ - application::{ - cache::{CacheMode, CacheTtl}, - handle::LoreApiHandle, - }, - domain::mailing_list::MailingList, - infrastructure::{ - http_lore_client::{MockFeedGateway, MockListsGateway, MockPatchHtmlGateway}, - patchset_fetcher::MockPatchsetFetcher, - patchset_parser::MockPatchsetParser, - persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, + 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}, + }, }, - }, - }; + }; + 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() { 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())); 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()); @@ -303,6 +341,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", "")])); @@ -311,11 +350,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/cache.rs b/src/lore/application/cache.rs index 591b1c7b..18d99138 100644 --- a/src/lore/application/cache.rs +++ b/src/lore/application/cache.rs @@ -1,74 +1,19 @@ use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, time::{Duration, SystemTime}, }; use crate::lore::{ application::dto::PatchTagSummary, - domain::{mailing_list::MailingList, patch::Patch, patchset::PatchFeedIndex}, + domain::{mailing_list::MailingList, 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, - } - } -} - -// ── 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); -} +use crate::lore::application::models::cache::{ + FeedCacheEntry, LoreCache, MailingListsCacheEntry, PatchsetCacheEntry, +}; // ── Mailing lists cache ─────────────────────────────────────────────────────── -pub struct MailingListsCacheEntry { - pub lists: Vec, - pub fetched_at: SystemTime, -} - impl MailingListsCacheEntry { pub fn new(lists: Vec) -> Self { MailingListsCacheEntry { @@ -84,15 +29,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 { @@ -108,28 +44,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 { - message_id: patch.message_id().href.clone(), - version: patch.version(), - } - } -} - -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, @@ -151,12 +65,6 @@ impl PatchsetCacheEntry { // ── Aggregate cache ─────────────────────────────────────────────────────────── -pub struct LoreCache { - pub lists: Option, - pub feeds: HashMap, - pub patchsets: HashMap, -} - impl LoreCache { pub fn new() -> Self { LoreCache { @@ -172,14 +80,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/handle.rs b/src/lore/application/handle.rs index 5b739e9b..61bc60ea 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}, }, @@ -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/messages.rs b/src/lore/application/messages.rs index 81ba640b..5512dec5 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 4b0a1ac4..500e49b1 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(crate) 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 00000000..691a9cd6 --- /dev/null +++ b/src/lore/application/models/cache.rs @@ -0,0 +1,92 @@ +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` 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, + Refresh, + #[expect(dead_code)] + Bypass, +} + +/// 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, + 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, +} + +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, + 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`, which initialises +/// `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 00000000..a5c08fdb --- /dev/null +++ b/src/lore/application/models/mod.rs @@ -0,0 +1 @@ +pub mod cache; diff --git a/src/lore/application/service.rs b/src/lore/application/service.rs index 38bfec7b..01a91026 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, @@ -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, PatchsetTextService}, persistence::{MailingListsCacheStore, UserLoreStateStore}, }, }, @@ -50,6 +50,7 @@ pub struct LoreService { } impl LoreService { + #[expect(clippy::too_many_arguments)] pub fn new( lists_gateway: Arc, feed_gateway: Arc, @@ -127,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; } @@ -206,7 +207,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`. @@ -224,8 +225,13 @@ 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 entry = self.cache.feeds.get_mut(target_list).unwrap(); + 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}" + ))); + }; entry.index.process_feed_page(feed); entry.index.advance_offset(); } @@ -250,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 => { @@ -285,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 = raw_patches + .iter() + .map(|p| Self::extract_tag_summary(p)) + .collect::>(); match mode { CacheMode::Bypass => {} @@ -318,8 +326,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(); @@ -336,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 = PatchsetTextService::generate_reply_template(patch); reply.push_str(&format!("\nReviewed-by: {git_signature}\n")); self.fs .write(&reply_path, reply.as_bytes()) @@ -346,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 = PatchsetTextService::extract_git_reply_command( &patch_html, git_send_email_options, &format!("{}", reply_path.display()), @@ -415,89 +424,116 @@ impl LoreService { } } -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 }); +impl LoreService { + fn extract_tag_summary(raw_patch: &str) -> PatchTagSummary { + let (cover, _) = PatchsetTextService::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 = 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, + } } } #[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::{ LoreHttpError, MockFeedGateway, MockListsGateway, MockPatchHtmlGateway, }, + parsers::LoreParserService, patchset_fetcher::MockPatchsetFetcher, patchset_parser::MockPatchsetParser, persistence::{MockMailingListsCacheStore, MockUserLoreStateStore}, }; - use crate::{ - infrastructure::{file_system::MockFileSystemTrait, shell::MockShellTrait}, - lore::{ - application::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] @@ -505,6 +541,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")])); @@ -518,7 +555,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()); } @@ -542,7 +581,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()); } @@ -558,7 +599,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() @@ -568,7 +609,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() @@ -578,12 +619,13 @@ 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(); lists_store .expect_save_available_lists() + .withf(|lists| lists.len() == 320) .times(1) .returning(|_| Ok(())); @@ -597,7 +639,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()); @@ -609,12 +653,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(())); @@ -634,7 +680,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). @@ -646,16 +694,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())); @@ -669,7 +720,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()); @@ -688,7 +739,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(), @@ -702,7 +753,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() @@ -715,6 +766,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)); @@ -749,11 +801,10 @@ mod tests { ); let feed = { - use crate::lore::infrastructure::parsers::parse_patch_feed; - let xml = fs::read_to_string(src).unwrap(); - 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(target.to_string()); + let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); svc.cache .feeds @@ -761,7 +812,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()); } @@ -774,8 +825,9 @@ 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).unwrap())); + .returning(move |_, _| Ok(fs::read_to_string(src).expect("file reads"))); let mut svc = make_service( MockListsGateway::new(), @@ -789,10 +841,10 @@ 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() + let xml = fs::read_to_string(src).expect("file reads"); + LoreParserService::parse_patch_feed(&xml).expect("patch feed parses") }; - let mut index = PatchFeedIndex::new(target.to_string()); + let mut index = PatchFeedIndex::new(); index.process_feed_page(feed); svc.cache .feeds @@ -800,22 +852,10 @@ 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()); } - // ── 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" - })) - .unwrap() - } - #[test] fn fetch_patchset_details_use_cache_hit() { // Pre-populate the cache; fetcher and parser must NOT be called. @@ -831,7 +871,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( @@ -843,7 +883,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()); } @@ -856,12 +896,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()])); @@ -877,10 +919,10 @@ 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(&patch); + let key = PatchsetCacheKey::from(&patch); assert!(svc.cache.patchsets.contains_key(&key)); } @@ -892,12 +934,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()])); @@ -912,7 +956,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![]), @@ -920,7 +964,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 571b9bc3..75eabeac 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 996d749c..1a8aa672 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,16 +45,6 @@ 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 { 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) { @@ -94,7 +96,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 +106,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 +116,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; + } } } } @@ -130,9 +138,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, @@ -171,7 +180,7 @@ mod tests { updated, } }; - let serialized_patch: &str = r#" + let serialized_patch = r#" Foo Bar @@ -186,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, @@ -221,7 +230,7 @@ mod tests { updated, } }; - let serialized_patch: &str = r#" + let serialized_patch = r#" Foo Bar @@ -239,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, @@ -249,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" @@ -288,4 +297,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 92140c8a..27b0f98b 100644 --- a/src/lore/domain/patchset.rs +++ b/src/lore/domain/patchset.rs @@ -5,11 +5,7 @@ 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 { - #[allow(dead_code)] - target_list: String, next_offset: usize, representative_patch_ids: Vec, patches_by_id: HashMap, @@ -17,9 +13,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 +22,6 @@ impl PatchFeedIndex { } } - #[allow(dead_code)] - pub fn target_list(&self) -> &str { - &self.target_list - } - pub fn next_offset(&self) -> usize { self.next_offset } @@ -40,11 +30,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) { @@ -77,13 +62,15 @@ 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(); 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() { @@ -99,7 +86,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 { @@ -125,27 +114,31 @@ impl PatchFeedIndex { #[cfg(test)] mod tests { - use std::fs; - use super::*; - use crate::lore::infrastructure::parsers::parse_patch_feed; + 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).unwrap(); - parse_patch_feed(&xml).unwrap() + 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() { - 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", ); @@ -155,14 +148,14 @@ 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).expect("entry is present"); assert_eq!("some/subsystem: Do this and that", patch.title()); assert_eq!(1, patch.version()); } #[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", ); @@ -174,7 +167,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", ); @@ -186,7 +179,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()); @@ -196,22 +189,22 @@ 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", ); 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/http_lore_client.rs b/src/lore/infrastructure/http_lore_client.rs index 0ce4654f..90f1a0d4 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/parsers.rs b/src/lore/infrastructure/parsers.rs index 2a5c5e41..88d4e3db 100644 --- a/src/lore/infrastructure/parsers.rs +++ b/src/lore/infrastructure/parsers.rs @@ -3,52 +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)
(.*?)
"#).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()); - - 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) - .map(|cap| cap.get(1).unwrap().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()) } - for capture in RE_LIST_NAME.captures_iter(pre_blocks[2]) { - let name = capture.get(1).unwrap().as_str().trim(); - list_names.push(name); - } + /// 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 = 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; + } - 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())), + ); - for (name, description) in list_names.into_iter().zip(list_descriptions) { - if name == "all" { - continue; + 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.push(MailingList::new(name, description)); - } - available_lists + available_lists + } } #[cfg(test)] @@ -62,9 +70,9 @@ 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 = 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()); @@ -81,16 +89,16 @@ 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 = 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()); + assert!(!feed.expect("feed loads").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 380c4c79..9fe112ef 100644 --- a/src/lore/infrastructure/patchset_fetcher.rs +++ b/src/lore/infrastructure/patchset_fetcher.rs @@ -42,10 +42,25 @@ impl B4PatchsetFetcher { } } +impl 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 + } +} + 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,36 +102,24 @@ 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::*; - use crate::infrastructure::{ - file_system::MockFileSystemTrait, - shell::{MockShellTrait, ShellOutput}, - }; - // 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; + + // 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"; - fn make_patch_with_message_id(message_id: &str) -> Patch { - let xml = format!( - r#" + pub(super) fn make_patch_with_message_id(message_id: &str) -> Patch { + let xml = format!( + r#" some/subsystem: Do this @@ -126,12 +129,21 @@ mod tests { "#, - ); - crate::lore::infrastructure::parsers::parse_patch_feed(&xml) - .unwrap() - .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, sync::atomic}; #[test] fn download_skips_b4_when_file_already_exists() { @@ -142,11 +154,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 @@ -168,19 +182,21 @@ 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(); 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, 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) }); @@ -215,18 +231,34 @@ 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().times(1).returning(|_| { - Err(crate::infrastructure::shell::ShellError::IoError( - std::io::Error::other("b4 not found"), - )) - }); + 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")))); 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 fd81fa67..8de64492 100644 --- a/src/lore/infrastructure/patchset_parser.rs +++ b/src/lore/infrastructure/patchset_parser.rs @@ -26,6 +26,60 @@ impl MboxPatchsetParser { } } +impl MboxPatchsetParser { + 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 { fn split_patchset(&self, patchset_path_str: &str) -> Result, String> { let mut patches: Vec = Vec::new(); @@ -40,161 +94,129 @@ 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) { - 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(); - - for line in mbox_reader.lines() { - let line = line.unwrap(); - - 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 PatchsetTextService; + +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 + /// 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 = ""; + + 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); - } -} -/// 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"#).unwrap() - }); + 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]+").unwrap()); + 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, + } } } #[cfg(test)] mod tests { - use std::fs; - use std::sync::Arc; - use crate::infrastructure::file_system::OsFileSystem; + mod helpers { - use super::*; + 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::{env, fs, process}; + + use super::*; #[test] fn split_patchset_returns_error_on_missing_path() { @@ -211,6 +233,22 @@ mod tests { ); } + #[test] + fn split_patchset_returns_error_on_invalid_utf8() { + 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() + .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() @@ -222,17 +260,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] ); } @@ -248,23 +286,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] ); } @@ -272,7 +310,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) = PatchsetTextService::split_cover(raw); assert!(cover.contains("Body text.")); assert!(diff.contains("diff --git")); } @@ -280,7 +318,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) = PatchsetTextService::split_cover(raw); assert_eq!(raw, cover); assert!(diff.is_empty()); } @@ -290,13 +328,16 @@ 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, generate_reply_template(&patch_sample)); + assert_eq!( + expected, + PatchsetTextService::generate_reply_template(&patch_sample) + ); } #[test] @@ -304,7 +345,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(), @@ -321,8 +362,11 @@ mod tests { ], }; - let result = - extract_git_reply_command(&patch_html, "--dry-run --suppress-cc=all", reply_path); + let result = PatchsetTextService::extract_git_reply_command( + &patch_html, + "--dry-run --suppress-cc=all", + reply_path, + ); assert!( commands_eq(&expected, &result), diff --git a/src/lore/infrastructure/persistence.rs b/src/lore/infrastructure/persistence.rs index 87420c91..15caa7d4 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) @@ -101,34 +103,51 @@ impl UserLoreStateStore for FileLorePersistence { #[cfg(test)] mod tests { - 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; + mod helpers { - use super::*; + use std::{env, fs, path::PathBuf, process, sync::Arc}; - fn tmp_dir(test_name: &str) -> std::path::PathBuf { - let dir = env::temp_dir().join(format!( - "patch-hub-persistence-{}-{}", - test_name, - std::process::id() - )); - fs::create_dir_all(&dir).unwrap(); - dir - } + use super::super::*; + use crate::infrastructure::file_system::OsFileSystem; + + 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 + } - fn make_persistence(dir: &std::path::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(), - ) + 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}, + fs, + }; + + use crate::lore::domain::mailing_list::MailingList; + + use super::*; #[test] fn available_lists_round_trip() { @@ -141,14 +160,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] @@ -161,14 +181,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] @@ -181,6 +204,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/main.rs b/src/main.rs index 97a6dfe0..b0579e3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,15 +11,17 @@ mod render_prefs; mod terminal; mod ui; -use app::{actor::AppActor, dependencies::check_external_deps, App}; +#[cfg(test)] +mod test_support; + +use app::{actor::AppActor, dependencies::DependencyService, App}; use clap::Parser; use cli::Cli; use color_eyre::{eyre::eyre, Result}; -use config::{bootstrap_parts, ConfigActor}; -#[cfg(unix)] -use infrastructure::process::OsProcess; +use config::{ConfigActor, ConfigService, ConfigSnapshot}; use infrastructure::{ env::OsEnv, + errors::{install_hooks, TerminalRestoreGuard}, file_system::{FileSystemTrait, OsFileSystem}, monitoring::{init_monitoring, InitMonitoringProduct}, net::UreqNetClient, @@ -27,11 +29,9 @@ 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}, + application::{actor::LoreApiActor, models::cache::CacheTtl, service::LoreService}, infrastructure::{ http_lore_client::HttpLoreGateway, patchset_fetcher::B4PatchsetFetcher, @@ -40,11 +40,12 @@ use lore::{ }, }; use render::{actor::RenderActor, ShellRenderService}; -use std::{ops::ControlFlow, path::Path, sync::Arc}; +use std::{ops::ControlFlow, sync::Arc}; use terminal::{actor::TerminalActor, session::CrosstermTerminalSession}; use tokio::sync::mpsc; use tracing::{event, Level}; use ui::actor::UiActor; +use {infrastructure::process::OsProcess, kw::actor::KwActor, std::path::Path}; #[tokio::main] async fn main() -> Result<()> { @@ -58,11 +59,12 @@ async fn main() -> Result<()> { let args = Cli::parse(); - infrastructure::errors::install_hooks()?; + install_hooks()?; let env = OsEnv; - let (config_state, config_repo) = bootstrap_parts(&env, OsFileSystem).map_err(|e| eyre!(e))?; - let config = config_state.to_snapshot(); + let (config_state, config_repo) = + ConfigService::bootstrap_parts(&env, OsFileSystem).map_err(|e| eyre!(e))?; + 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( @@ -76,128 +78,124 @@ 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()?))); - 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(), - )); - - // 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), - shell_arc.clone(), - fs_arc.clone(), - 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()))); - - 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 = 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() + .await + .map_err(|error| eyre!("{error}"))?, + config_handle.clone(), + bootstrap, + fs_arc.clone(), + Box::new(OsShell), + lore_api.clone(), + render.clone(), + kw_history.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()); + input_handle + .subscribe_app(app_input_tx) + .await + .map_err(|e| eyre!("{e}"))?; + let input_shutdown_handle = input_handle.clone(); + + // 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(), + ui_handle.clone(), + input_handle, + app_input_rx, + ) + .run_until_done() + .await?; + input_shutdown_handle + .shutdown() .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 { + .map_err(|e| eyre!("{e}"))?; 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 { + 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(()) } diff --git a/src/render/actor.rs b/src/render/actor.rs index fa1b13ba..c2ce3b38 100644 --- a/src/render/actor.rs +++ b/src/render/actor.rs @@ -1,19 +1,13 @@ -//! Render actor: serializes patch/cover preview rendering on a dedicated task. +//! Render actor: serializes patch and cover preview rendering. //! -//! [`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)) -//! 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, oneshot}, - task, -}; +use tokio::{spawn, sync::mpsc, task}; +use crate::infrastructure::actor_reply::ActorReplyService; use crate::render::{ handle::RenderHandle, messages::{RenderMessage, RenderResult}, @@ -22,6 +16,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, @@ -54,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"); @@ -71,7 +70,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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); ControlFlow::Continue(()) } RenderMessage::Shutdown => { @@ -101,43 +106,25 @@ 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; + + 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/render/cover_renderer.rs b/src/render/cover_renderer.rs index fd36de35..b78be25c 100644 --- a/src/render/cover_renderer.rs +++ b/src/render/cover_renderer.rs @@ -7,29 +7,39 @@ 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 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)?) +impl CoverRendererService { + /// 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) + })?; + + Ok(String::from_utf8(out.stdout)?) + } } diff --git a/src/render/handle.rs b/src/render/handle.rs index 8a96414e..22b5994f 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/mod.rs b/src/render/mod.rs index 383ea527..772ec124 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -17,8 +17,8 @@ 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}, + lore::infrastructure::patchset_parser::PatchsetTextService, + render::{cover_renderer::CoverRendererService, patch_renderer::PatchRendererService}, }; /// [`RenderServiceApi`] backed by [`ShellTrait`] and the existing `app` renderer helpers. @@ -41,28 +41,33 @@ 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 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 (raw_cover, raw_diff) = PatchsetTextService::split_cover(&raw_patch_expanded); + 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 62e50d1b..73c360a1 100644 --- a/src/render/patch_renderer.rs +++ b/src/render/patch_renderer.rs @@ -6,118 +6,103 @@ 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 { + 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) } } -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) -} +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 = 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. -/// -/// # 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 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); -/// 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)?) -} + 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) + })?; -/// 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)?) + Ok(String::from_utf8(out.stdout)?) + } + + /// 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); + + 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 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); + + 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)?) + } } diff --git a/src/terminal/actor.rs b/src/terminal/actor.rs index 510b6bd6..d5107ae1 100644 --- a/src/terminal/actor.rs +++ b/src/terminal/actor.rs @@ -1,16 +1,11 @@ //! 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 -//! 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, -}; +//! `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; use crate::terminal::{ handle::TerminalHandle, messages::{TerminalMessage, TerminalResult}, @@ -20,6 +15,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, @@ -50,7 +48,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"); @@ -61,36 +61,52 @@ impl TerminalActor { .with_session(move |session| session.draw(frame)) .await .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); + ActorReplyService::send_actor_reply( + message_name, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + 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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + 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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + 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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); } TerminalMessage::WaitForKeyPress { key, @@ -101,21 +117,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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + 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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + 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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply, + result, + ); } } } @@ -140,45 +174,27 @@ 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 { + + 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; - use crate::{ - input::event::{KeyInput, TerminalEvent}, - terminal::messages::TerminalFrame, - terminal::session::MockTerminalSessionApi, - }; + use crate::{terminal::messages::TerminalFrame, terminal::session::MockTerminalSessionApi}; 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(); @@ -194,24 +210,6 @@ mod tests { assert!(result.is_ok()); } - #[tokio::test] - async fn read_event_returns_terminal_event_from_actor() { - let expected = TerminalEvent::Key(KeyInput::press(KeyCode::Char('j'))); - 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::press(KeyCode::Char('j')))) - ); - } - #[tokio::test] async fn sequential_requests_preserve_session() { let mut session = MockTerminalSessionApi::new(); @@ -220,11 +218,18 @@ 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.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)); } @@ -242,7 +247,7 @@ mod tests { let pressed = handle .wait_for_key_press(KeyCode::Enter, Duration::from_millis(50)) .await - .unwrap(); + .expect("key press completes"); assert!(pressed); } @@ -250,10 +255,14 @@ 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.unwrap(); + handle.setup_user_io().await.expect("user io sets up"); } #[tokio::test] @@ -261,21 +270,26 @@ mod tests { let mut session = MockTerminalSessionApi::new(); session .expect_teardown_user_io() + .withf(|| true) .times(1) .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] 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.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/terminal/handle.rs b/src/terminal/handle.rs index 040a5336..26ca8bda 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 @@ -70,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/messages.rs b/src/terminal/messages.rs index 7e4a9e39..404d7d0a 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", diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 48bb5214..03beb4f8 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 [`handle::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 -//! 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/terminal/session.rs b/src/terminal/session.rs index db4b4d94..c43feb5b 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. @@ -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, } @@ -57,10 +62,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(|_| {})?; @@ -92,7 +98,21 @@ 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) + 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)> { @@ -111,28 +131,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::{ diff --git a/src/test_support.rs b/src/test_support.rs new file mode 100644 index 00000000..cd353240 --- /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); + } +} diff --git a/src/ui/actor.rs b/src/ui/actor.rs index c6aab859..f5b00163 100644 --- a/src/ui/actor.rs +++ b/src/ui/actor.rs @@ -1,28 +1,20 @@ -//! UI presentation actor: builds [`UiScene`](crate::ui::scene::UiScene) values -//! from [`AppViewModel`](crate::app::view_model::AppViewModel) on a dedicated task. +//! UI presentation actor: builds `UiScene` values from `AppViewModel`. //! -//! [`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 -//! 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, 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; +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, @@ -52,7 +44,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"); @@ -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, + REQUEST_FAILED_LOG, + REPLY_DROPPED_LOG, + reply_to, + result, + ); ControlFlow::Continue(()) } UiMessage::Shutdown => { @@ -77,56 +77,42 @@ 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; - use crate::{ - app::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 fcc83acd..b682e371 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}, @@ -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, @@ -52,7 +57,9 @@ impl UiCore { popup, }) } +} +impl UiCore { fn build_navigation( &self, screen: &ScreenViewModel, @@ -60,28 +67,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 { @@ -106,35 +113,38 @@ impl Default for UiCore { #[cfg(test)] mod tests { - use crate::app::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) - .unwrap() - .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/handle.rs b/src/ui/handle.rs index a8729f05..ad3d3d07 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}, @@ -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 3a022b5b..c1637131 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,64 @@ 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 -} +pub(crate) struct LoadingPainter; -/// 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()); +impl LoadingPainter { + /// 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) = - calculate_loading_percentages(loading_text.len(), frame_area.width, frame_area.height); + let (width_pct, height_pct) = Self::calculate_loading_percentages( + loading_text.len(), + frame_area.width, + frame_area.height, + ); - let loading_area = centered_rect(width_pct, height_pct, frame_area); + 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 }); + 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); + 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) +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 + /// + /// [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 +92,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/messages.rs b/src/ui/messages.rs index 4f6d7854..c675f8de 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/painter.rs b/src/ui/painter.rs index e0ba5de1..46c0a29f 100644 --- a/src/ui/painter.rs +++ b/src/ui/painter.rs @@ -17,71 +17,81 @@ 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]); - screens::navigation_bar::paint(f, &scene.navigation, chunks[2]); + 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]), + } - 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); - } -} + screens::navigation_bar::NavigationBarPainter::paint(f, &scene.navigation, chunks[2]); -fn paint_title(f: &mut Frame, chunk: Rect) { - let title_block = Block::default() - .borders(Borders::ALL) - .style(Style::default()) - .title_alignment(Alignment::Center); + 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); + } + } - let title = Paragraph::new(Text::styled( - "patch-hub", - Style::default().fg(Color::Green).bold(), - )) - .centered() - .block(title_block); + 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); - f.render_widget(title, chunk); + 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] + } } -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); +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); - 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] + f.render_widget(title, chunk); + } } diff --git a/src/ui/scene.rs b/src/ui/scene.rs index f4202613..3a84f593 100644 --- a/src/ui/scene.rs +++ b/src/ui/scene.rs @@ -1,15 +1,11 @@ -//! Scene types produced by [`super::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}; -// 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 467002ce..69bf577b 100644 --- a/src/ui/screens/bookmarked.rs +++ b/src/ui/screens/bookmarked.rs @@ -6,67 +6,75 @@ use ratatui::{ Frame, }; -use crate::{app::view_model::BookmarkedViewModel, ui::scene::BookmarkedScene}; +use crate::{app::models::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 5aeaa586..232d8551 100644 --- a/src/ui/screens/details.rs +++ b/src/ui/screens/details.rs @@ -7,243 +7,253 @@ use ratatui::{ }; use crate::{ - app::view_model::PatchsetDetailsViewModel, - ui::scene::{PatchsetDetailsScene, TagTrailerCounts}, + app::models::view_model::{PatchsetDetailsViewModel, TagTrailerCounts}, + ui::scene::PatchsetDetailsScene, }; -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)), + 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( - 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)), - ]) + "(ESC / q) to return | (ENTER) run actions | (w) kw | (?) help", + Style::default().fg(Color::Red), + ) + } } -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), - ), - ]), +impl DetailsPainter { + 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) + } + }; + Line::from(vec![ - Span::styled("Patch count: ", Style::default().fg(Color::Cyan)), + Span::styled("Reviewed-by: ", Style::default().fg(Color::Cyan)), Span::styled( - format!("{}", scene.patch_count), - Style::default().fg(Color::White), + counts.reviewed_by.to_string(), + resolve_color(counts.reviewed_by), ), - ]), - Line::from(vec![ - Span::styled("Last updated: ", Style::default().fg(Color::Cyan)), + Span::styled(" | Tested-by: ", Style::default().fg(Color::Cyan)), Span::styled( - scene.last_updated.clone(), - Style::default().fg(Color::White), + counts.tested_by.to_string(), + resolve_color(counts.tested_by), ), - ]), - 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), + 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(); -pub fn keys_hint_span() -> Span<'static> { - Span::styled( - "(ESC / q) to return | (ENTER) run actions | (w) kw | (?) help", - Style::default().fg(Color::Red), - ) + 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); + } } diff --git a/src/ui/screens/edit_config.rs b/src/ui/screens/edit_config.rs index db72efd6..0843130b 100644 --- a/src/ui/screens/edit_config.rs +++ b/src/ui/screens/edit_config.rs @@ -6,120 +6,132 @@ use ratatui::{ Frame, }; -use crate::{app::view_model::EditConfigViewModel, ui::scene::EditConfigScene}; +use crate::{app::models::view_model::EditConfigViewModel, ui::scene::EditConfigScene}; -pub fn build_scene(vm: &EditConfigViewModel) -> EditConfigScene { - EditConfigScene { - entries: vm.entries.clone(), - } -} - -pub fn paint(f: &mut Frame, scene: &EditConfigScene, chunk: Rect) { - let mut constraints = Vec::new(); +pub struct EditConfigPainter; - 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), + ) + } } } #[cfg(test)] mod tests { - use super::*; - use crate::app::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() { - 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 69d3fe72..fa87def7 100644 --- a/src/ui/screens/kw_ops.rs +++ b/src/ui/screens/kw_ops.rs @@ -6,186 +6,202 @@ use ratatui::{ Frame, }; -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(), - } -} +use crate::{app::models::view_model::KwOpsViewModel, ui::scene::KwOpsScene}; -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 struct KwOpsPainter; - paint_form(f, scene, chunks[0]); - 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); -} +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(), + } + } -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); -} + 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); -/// 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 -} + Self::paint_form(f, scene, chunks[0]); + Self::paint_log(f, scene, chunks[1]); + } -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` 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, + 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)), - ]) -} + pub fn build_mode_spans() -> Vec> { + vec![Span::styled( + "Kw operations", + Style::default().fg(Color::Green), + )] + } -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); + pub fn build_keys_hint_span(editing: bool) -> Span<'static> { if editing { - style = style.fg(Color::LightYellow); + Span::styled( + "(ESC) cancel edit | (ENTER) confirm", + Style::default().fg(Color::Red), + ) } else { - style = style.fg(Color::Yellow); + Span::styled( + "(ESC / q) back | (e) edit | (b) build | (d) deploy | (D) build+deploy | (c) cancel | (r) restore | (?) help", + Style::default().fg(Color::Red), + ) } } - 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) } -pub fn mode_spans() -> Vec> { - vec![Span::styled( - "Kw operations", - Style::default().fg(Color::Green), - )] -} +impl KwOpsPainter { + 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), + ]); -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( - "(ESC / q) back | (e) edit | (b) build | (d) deploy | (D) build+deploy | (c) cancel | (r) restore | (?) help", - Style::default().fg(Color::Red), - ) + 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_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 build_labeled_line(label: &str, value: &str) -> Line<'static> { + Self::build_colored_label(label, value, Color::White) + } + + 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)), + ]) + } + + 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) } } @@ -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 0b1dc462..6550af26 100644 --- a/src/ui/screens/latest.rs +++ b/src/ui/screens/latest.rs @@ -6,70 +6,78 @@ use ratatui::{ Frame, }; -use crate::{app::view_model::LatestPatchsetsViewModel, ui::scene::LatestScene}; +use crate::{app::models::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 868cf444..092db4a1 100644 --- a/src/ui/screens/mailing_list.rs +++ b/src/ui/screens/mailing_list.rs @@ -7,80 +7,85 @@ use ratatui::{ }; use crate::{ - app::view_model::{MailingListSelectionViewModel, TargetListStatus}, + app::models::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 40b291b6..e23c49fd 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 08fed1c0..31f31a9b 100644 --- a/src/ui/screens/popup.rs +++ b/src/ui/screens/popup.rs @@ -7,236 +7,244 @@ use ratatui::{ }; use crate::{ - app::view_model::{PopupViewBody, PopupViewModel}, + app::models::view_model::{PopupViewBody, PopupViewModel}, 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); +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) + .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); + } }