diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0596b3c55a..f9f5f3dcb5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -71,6 +71,7 @@ dependencies = [ "agama-software", "agama-utils", "async-trait", + "gettext-rs", "serde_json", "strum", "tempfile", diff --git a/rust/agama-files/Cargo.toml b/rust/agama-files/Cargo.toml index 78574b5441..e5c9427476 100644 --- a/rust/agama-files/Cargo.toml +++ b/rust/agama-files/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true agama-software = { version = "0.1.0", path = "../agama-software" } agama-utils = { path = "../agama-utils" } async-trait = "0.1.89" +gettext-rs = { version = "0.7.7", features = ["gettext-system"] } strum = "0.27.2" tempfile = "3.23.0" thiserror = "2.0.17" diff --git a/rust/agama-files/src/service.rs b/rust/agama-files/src/service.rs index d27059190a..459841fa76 100644 --- a/rust/agama-files/src/service.rs +++ b/rust/agama-files/src/service.rs @@ -26,13 +26,18 @@ use std::{ use agama_software::{self as software, Resolvable, ResolvableType}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, - api::files::{ - scripts::{self, ScriptsGroup, ScriptsRepository}, - user_file, ScriptsConfig, UserFile, + api::{ + files::{ + scripts::{self, ScriptsGroup, ScriptsRepository}, + user_file, Script, ScriptsConfig, UserFile, + }, + question::QuestionSpec, }, - progress, question, + progress, + question::{self, ask_question, AskError}, }; use async_trait::async_trait; +use gettextrs::gettext; use strum::IntoEnumIterator; use tokio::sync::Mutex; @@ -48,6 +53,8 @@ pub enum Error { Software(#[from] software::service::Error), #[error(transparent)] Actor(#[from] actor::Error), + #[error(transparent)] + Questions(#[from] AskError), } const DEFAULT_SCRIPTS_DIR: &str = "run/agama/scripts"; @@ -142,29 +149,28 @@ impl Service { } pub async fn add_scripts(&mut self, config: ScriptsConfig) -> Result<(), Error> { - let mut repo = self.scripts.lock().await; if let Some(scripts) = config.pre { for pre in scripts { - repo.add(pre.into())?; + self.add_script(pre.into()).await?; } } if let Some(scripts) = config.post_partitioning { for post in scripts { - repo.add(post.into())?; + self.add_script(post.into()).await?; } } if let Some(scripts) = config.post { for post in scripts { - repo.add(post.into())?; + self.add_script(post.into()).await?; } } let mut packages = vec![]; if let Some(scripts) = config.init { for init in scripts { - repo.add(init.into())?; + self.add_script(init.into()).await?; } packages.push(Resolvable::new("agama-scripts", ResolvableType::Package)); } @@ -176,6 +182,59 @@ impl Service { .await?; Ok(()) } + + async fn add_script(&self, script: Script) -> Result<(), Error> { + let result = { + let mut repo = self.scripts.lock().await; + repo.add(script.clone()) + }; + + let mut attempt = 1; + while let Err(error) = &result { + tracing::error!("Failed to write the script {}: {error}.", script.name()); + + // TRANSLATORS: %s is replaced by the script name. + let text = &gettext("Failed to retrieve the script %s. Do you want to try again?") + .replace("%s", script.name()); + let question = QuestionSpec::new(text, "write_script_failed") + .with_yes_no_actions() + .with_data(&[ + ("attempt", &attempt.to_string()), + ("details", &error.to_string()), + ]); + let answer = ask_question(&self.questions, question).await?; + if answer.action == "No" { + return Ok(()); + } + attempt += 1; + } + + Ok(()) + } + + async fn write_file(&self, file: &UserFile) -> Result<(), Error> { + let mut attempt = 1; + while let Err(error) = file.write(&self.install_dir).await { + tracing::error!("Failed to write the file {}: {error}.", file.destination); + + // TRANSLATORS: %s is replaced by the script name. + let text = &gettext("Failed to write the file %s. Do you want to try again?") + .replace("%s", &file.destination); + let question = QuestionSpec::new(text, "write_file_failed") + .with_yes_no_actions() + .with_data(&[ + ("attempt", &attempt.to_string()), + ("details", &error.to_string()), + ]); + let answer = ask_question(&self.questions, question).await?; + if answer.action == "No" { + return Ok(()); + } + attempt += 1; + } + + Ok(()) + } } impl Actor for Service { @@ -237,9 +296,7 @@ impl MessageHandler for Service { impl MessageHandler for Service { async fn handle(&mut self, _message: message::WriteFiles) -> Result<(), Error> { for file in &self.files { - if let Err(error) = file.write(&self.install_dir).await { - tracing::error!("Failed to write file {}: {error}", file.destination); - } + self.write_file(file).await?; } Ok(()) } diff --git a/rust/package/agama.changes b/rust/package/agama.changes index aa4773211d..6fcd2172fc 100644 --- a/rust/package/agama.changes +++ b/rust/package/agama.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Mar 18 22:29:10 UTC 2026 - Imobach Gonzalez Sosa + +- Report problems when writing files and scripts (gh#agama-project/agama#3301). + ------------------------------------------------------------------- Tue Mar 17 14:49:14 UTC 2026 - Michal Filka diff --git a/web/package/agama-web-ui.changes b/web/package/agama-web-ui.changes index e6053763ff..8e5c3a7250 100644 --- a/web/package/agama-web-ui.changes +++ b/web/package/agama-web-ui.changes @@ -1,3 +1,8 @@ +------------------------------------------------------------------- +Wed Mar 18 22:29:48 UTC 2026 - Imobach Gonzalez Sosa + +- Initial support for questions with details (gh#agama-project/agama#3301). + ------------------------------------------------------------------- Tue Mar 17 17:07:58 UTC 2026 - David Diaz diff --git a/web/src/components/questions/GenericQuestion.test.tsx b/web/src/components/questions/GenericQuestion.test.tsx index 5244247335..8464e21a8e 100644 --- a/web/src/components/questions/GenericQuestion.test.tsx +++ b/web/src/components/questions/GenericQuestion.test.tsx @@ -39,6 +39,21 @@ const question: Question = { defaultAction: "sometimes", }; +const questionWithDetails: Question = { + id: 1, + text: "Failed to fetch the script. Do you want to retry?", + class: "failed-script", + field: { type: FieldType.None }, + actions: [ + { id: "yes", label: "Yes" }, + { id: "no", label: "No" }, + ], + data: { + details: "Some details", + }, + defaultAction: "yes", +}; + const answerFn = jest.fn(); const renderQuestion = () => @@ -51,6 +66,15 @@ describe("GenericQuestion", () => { await screen.findByText(question.text); }); + describe("if there are details", () => { + it("renders the question details", async () => { + plainRender(); + + await screen.findByText(questionWithDetails.text); + await screen.findByText(questionWithDetails.data.details); + }); + }); + it("sets chosen option and calls the callback after user clicking an action", async () => { const { user } = renderQuestion(); diff --git a/web/src/components/questions/GenericQuestion.tsx b/web/src/components/questions/GenericQuestion.tsx index 4243dedc43..d3182bb9b9 100644 --- a/web/src/components/questions/GenericQuestion.tsx +++ b/web/src/components/questions/GenericQuestion.tsx @@ -45,9 +45,12 @@ export default function GenericQuestion({ answerCallback(question); }; + const details = question.data?.details; + return ( - {question.text} + {question.text} + {details && {details}}