diff --git a/doc/answers_example.yaml b/doc/answers_example.yaml
new file mode 100644
index 0000000000..581f39ff1d
--- /dev/null
+++ b/doc/answers_example.yaml
@@ -0,0 +1,3 @@
+answers:
+ - class: storage.luks_activation
+ answer: "skip"
diff --git a/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml b/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml
index c9d9c8a126..12ed3ad5c5 100644
--- a/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml
+++ b/doc/dbus/bus/org.opensuse.Agama.Questions1.bus.xml
@@ -87,10 +87,13 @@
+
+
+
-
-
+
diff --git a/doc/dbus/org.opensuse.Agama.Questions1.doc.xml b/doc/dbus/org.opensuse.Agama.Questions1.doc.xml
index eddea73a4a..e32cdbb56f 100644
--- a/doc/dbus/org.opensuse.Agama.Questions1.doc.xml
+++ b/doc/dbus/org.opensuse.Agama.Questions1.doc.xml
@@ -76,18 +76,30 @@ when the question is answered and the answer is successfully read.
+
-
+
-
-
+
+
+
+
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 745095d4c7..56c4fa26c9 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -36,6 +36,8 @@ dependencies = [
"async-std",
"log",
"parking_lot",
+ "serde",
+ "serde_yaml",
"simplelog",
"systemd-journal-logger",
"thiserror",
@@ -665,6 +667,12 @@ dependencies = [
"syn 2.0.16",
]
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
[[package]]
name = "errno"
version = "0.3.1"
@@ -883,6 +891,12 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+[[package]]
+name = "hashbrown"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
+
[[package]]
name = "heck"
version = "0.4.1"
@@ -927,7 +941,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
- "hashbrown",
+ "hashbrown 0.12.3",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.14.0",
]
[[package]]
@@ -1605,11 +1629,11 @@ dependencies = [
[[package]]
name = "serde_yaml"
-version = "0.9.21"
+version = "0.9.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9d684e3ec7de3bf5466b32bd75303ac16f0736426e5a4e0d6e489559ce1249c"
+checksum = "bd5f51e3fdb5b9cdd1577e1cb7a733474191b1aca6a72c2e50913241632c1180"
dependencies = [
- "indexmap",
+ "indexmap 2.0.0",
"itoa",
"ryu",
"serde",
@@ -1847,7 +1871,7 @@ version = "0.19.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13"
dependencies = [
- "indexmap",
+ "indexmap 1.9.3",
"toml_datetime",
"winnow",
]
diff --git a/rust/agama-cli/src/questions.rs b/rust/agama-cli/src/questions.rs
index d0a22c1c00..8bce0cb928 100644
--- a/rust/agama-cli/src/questions.rs
+++ b/rust/agama-cli/src/questions.rs
@@ -1,12 +1,15 @@
use agama_lib::connection;
use agama_lib::proxies::Questions1Proxy;
-use anyhow::{Context, Ok};
+use anyhow::Context;
use clap::{Args, Subcommand, ValueEnum};
#[derive(Subcommand, Debug)]
pub enum QuestionsCommands {
/// Set mode for answering questions.
Mode(ModesArgs),
+ Answers {
+ path: String,
+ },
}
#[derive(Args, Debug)]
@@ -20,29 +23,31 @@ pub enum Modes {
Interactive,
NonInteractive,
}
-// TODO when more commands is added, refactor and add it to agama-lib and share a bit of functionality
-async fn set_mode(value: Modes) -> anyhow::Result<()> {
- match value {
- Modes::NonInteractive => {
- let connection = connection().await?;
- let proxy = Questions1Proxy::new(&connection)
- .await
- .context("Failed to connect to Questions service")?;
- // TODO: how to print dbus error in that anyhow?
- proxy
- .use_default_answer()
- .await
- .context("Failed to set default answer")?;
- }
- Modes::Interactive => log::info!("not implemented"), //TODO do it
- }
+async fn set_mode(proxy: Questions1Proxy<'_>, value: Modes) -> anyhow::Result<()> {
+ // TODO: how to print dbus error in that anyhow?
+ proxy
+ .set_interactive(value == Modes::Interactive)
+ .await
+ .context("Failed to set mode for answering questions.")
+}
- Ok(())
+async fn set_answers(proxy: Questions1Proxy<'_>, path: String) -> anyhow::Result<()> {
+ // TODO: how to print dbus error in that anyhow?
+ proxy
+ .add_answer_file(path.as_str())
+ .await
+ .context("Failed to set answers from answers file")
}
pub async fn run(subcommand: QuestionsCommands) -> anyhow::Result<()> {
+ let connection = connection().await?;
+ let proxy = Questions1Proxy::new(&connection)
+ .await
+ .context("Failed to connect to Questions service")?;
+
match subcommand {
- QuestionsCommands::Mode(value) => set_mode(value.value).await,
+ QuestionsCommands::Mode(value) => set_mode(proxy, value.value).await,
+ QuestionsCommands::Answers { path } => set_answers(proxy, path).await,
}
}
diff --git a/rust/agama-dbus-server/Cargo.toml b/rust/agama-dbus-server/Cargo.toml
index dff9b917f1..f74f49d0d2 100644
--- a/rust/agama-dbus-server/Cargo.toml
+++ b/rust/agama-dbus-server/Cargo.toml
@@ -18,3 +18,5 @@ async-std = { version = "1.12.0", features = ["attributes"]}
uuid = { version = "1.3.4", features = ["v4"] }
parking_lot = "0.12.1"
thiserror = "1.0.40"
+serde = { version = "1.0.152", features = ["derive"] }
+serde_yaml = "0.9.24"
\ No newline at end of file
diff --git a/rust/agama-dbus-server/src/network/action.rs b/rust/agama-dbus-server/src/network/action.rs
index e0592e7fa4..9f935f70f1 100644
--- a/rust/agama-dbus-server/src/network/action.rs
+++ b/rust/agama-dbus-server/src/network/action.rs
@@ -3,7 +3,7 @@ use agama_lib::network::types::DeviceType;
/// Networking actions, like adding, updating or removing connections.
///
-/// These actions are meant to be processed by [crate::system::NetworkSystem], updating the model
+/// These actions are meant to be processed by [crate::network::system::NetworkSystem], updating the model
/// and the D-Bus tree as needed.
#[derive(Debug)]
pub enum Action {
diff --git a/rust/agama-dbus-server/src/network/dbus/interfaces.rs b/rust/agama-dbus-server/src/network/dbus/interfaces.rs
index e85f1d188e..db5e2ad118 100644
--- a/rust/agama-dbus-server/src/network/dbus/interfaces.rs
+++ b/rust/agama-dbus-server/src/network/dbus/interfaces.rs
@@ -82,7 +82,7 @@ impl Device {
///
/// Possible values: 0 = loopback, 1 = ethernet, 2 = wireless.
///
- /// See [crate::model::DeviceType].
+ /// See [agama_lib::network::types::DeviceType].
#[dbus_interface(property, name = "Type")]
pub fn device_type(&self) -> u8 {
self.device.type_ as u8
@@ -124,7 +124,7 @@ impl Connections {
/// Adds a new network connection.
///
/// * `id`: connection name.
- /// * `ty`: connection type (see [crate::model::DeviceType]).
+ /// * `ty`: connection type (see [agama_lib::network::types::DeviceType]).
pub async fn add_connection(&mut self, id: String, ty: u8) -> zbus::fdo::Result<()> {
let actions = self.actions.lock();
actions
@@ -274,7 +274,7 @@ impl Ipv4 {
///
/// Possible values: "disabled", "auto", "manual" or "link-local".
///
- /// See [crate::model::IpMethod].
+ /// See [crate::network::model::IpMethod].
#[dbus_interface(property)]
pub fn method(&self) -> String {
let connection = self.get_connection();
@@ -401,7 +401,7 @@ impl Wireless {
///
/// Possible values: "unknown", "adhoc", "infrastructure", "ap" or "mesh".
///
- /// See [crate::model::WirelessMode].
+ /// See [crate::network::model::WirelessMode].
#[dbus_interface(property)]
pub fn mode(&self) -> String {
let connection = self.get_wireless();
@@ -442,7 +442,7 @@ impl Wireless {
/// Possible values: "none", "owe", "ieee8021x", "wpa-psk", "sae", "wpa-eap",
/// "wpa-eap-suite-b192".
///
- /// See [crate::model::SecurityProtocol].
+ /// See [crate::network::model::SecurityProtocol].
#[dbus_interface(property)]
pub fn security(&self) -> String {
let connection = self.get_wireless();
diff --git a/rust/agama-dbus-server/src/questions.rs b/rust/agama-dbus-server/src/questions.rs
index b84a3a834d..21afb6606c 100644
--- a/rust/agama-dbus-server/src/questions.rs
+++ b/rust/agama-dbus-server/src/questions.rs
@@ -9,6 +9,8 @@ use anyhow::Context;
use log;
use zbus::{dbus_interface, fdo::ObjectManager, zvariant::ObjectPath, Connection};
+mod answers;
+
#[derive(Clone, Debug)]
struct GenericQuestionObject(questions::GenericQuestion);
@@ -81,7 +83,11 @@ enum QuestionType {
}
/// Trait for objects that can provide answers to all kind of Question.
+///
+/// If no strategy is selected or the answer is unknown, then ask to the user.
trait AnswerStrategy {
+ /// Id for quick runtime inspection of strategy type
+ fn id(&self) -> u8;
/// Provides answer for generic question
///
/// I gets as argument the question to answer. Returned value is `answer`
@@ -103,7 +109,17 @@ trait AnswerStrategy {
/// AnswerStrategy that provides as answer the default option.
struct DefaultAnswers;
+impl DefaultAnswers {
+ pub fn id() -> u8 {
+ 1
+ }
+}
+
impl AnswerStrategy for DefaultAnswers {
+ fn id(&self) -> u8 {
+ DefaultAnswers::id()
+ }
+
fn answer(&self, question: &GenericQuestion) -> Option {
Some(question.default_option.clone())
}
@@ -227,11 +243,43 @@ impl Questions {
Ok(())
}
- /// sets questions to be answered by default answer instead of asking user
- async fn use_default_answer(&mut self) -> Result<(), Error> {
- log::info!("Answer questions with default option");
- self.answer_strategies.push(Box::new(DefaultAnswers {}));
- Ok(())
+ /// property that defines if questions is interactive or automatically answered with
+ /// default answer
+ #[dbus_interface(property)]
+ fn interactive(&self) -> bool {
+ let last = self.answer_strategies.last();
+ if let Some(real_strategy) = last {
+ real_strategy.id() != DefaultAnswers::id()
+ } else {
+ true
+ }
+ }
+
+ #[dbus_interface(property)]
+ fn set_interactive(&mut self, value: bool) {
+ if value != self.interactive() {
+ log::info!("interactive value unchanged - {}", value);
+ return;
+ }
+
+ log::info!("set interactive to {}", value);
+ if value {
+ self.answer_strategies.pop();
+ } else {
+ self.answer_strategies.push(Box::new(DefaultAnswers {}));
+ }
+ }
+
+ fn add_answer_file(&mut self, path: String) -> Result<(), Error> {
+ log::info!("Adding answer file {}", path);
+ let answers = answers::Answers::new_from_file(path.as_str());
+ match answers {
+ Ok(answers) => {
+ self.answer_strategies.push(Box::new(answers));
+ Ok(())
+ }
+ Err(e) => Err(e.into()),
+ }
}
}
diff --git a/rust/agama-dbus-server/src/questions/answers.rs b/rust/agama-dbus-server/src/questions/answers.rs
new file mode 100644
index 0000000000..a955093fdd
--- /dev/null
+++ b/rust/agama-dbus-server/src/questions/answers.rs
@@ -0,0 +1,291 @@
+use std::collections::HashMap;
+
+use anyhow::Context;
+use serde::{Deserialize, Serialize};
+
+/// Data structure for single yaml answer. For variables specification see
+/// corresponding [agama_lib::questions::GenericQuestion] fields.
+/// The *matcher* part is: `class`, `text`, `data`.
+/// The *answer* part is: `answer`, `password`.
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+struct Answer {
+ pub class: Option,
+ pub text: Option,
+ /// A matching GenericQuestion can have other data fields too
+ pub data: Option>,
+ /// The answer text is the only mandatory part of an Answer
+ pub answer: String,
+ /// All possible mixins have to be here, so they can be specified in an Answer
+ pub password: Option,
+}
+
+/// Data structure holding list of Answer.
+/// The first matching Answer is used, even if there is
+/// a better (more specific) match later in the list.
+#[derive(Serialize, Deserialize, PartialEq, Debug)]
+pub struct Answers {
+ answers: Vec,
+}
+
+impl Answers {
+ pub fn new_from_file(path: &str) -> anyhow::Result {
+ let f = std::fs::File::open(path).context(format!("Failed to open {}", path))?;
+ let result: Self =
+ serde_yaml::from_reader(f).context(format!("Failed to parse values at {}", path))?;
+
+ Ok(result)
+ }
+
+ pub fn id() -> u8 {
+ 2
+ }
+
+ fn find_answer(&self, question: &agama_lib::questions::GenericQuestion) -> Option<&Answer> {
+ 'main: for answerd in self.answers.iter() {
+ if let Some(v) = &answerd.class {
+ if !question.class.eq(v) {
+ continue;
+ }
+ }
+ if let Some(v) = &answerd.text {
+ if !question.text.eq(v) {
+ continue;
+ }
+ }
+ if let Some(v) = &answerd.data {
+ for (key, value) in v {
+ // all keys defined in answer has to match
+ let entry = question.data.get(key);
+ if let Some(e_val) = entry {
+ if !e_val.eq(value) {
+ continue 'main;
+ }
+ } else {
+ continue 'main;
+ }
+ }
+ }
+
+ return Some(answerd);
+ }
+
+ None
+ }
+}
+
+impl crate::questions::AnswerStrategy for Answers {
+ fn id(&self) -> u8 {
+ Answers::id()
+ }
+
+ fn answer(&self, question: &agama_lib::questions::GenericQuestion) -> Option {
+ let answer = self.find_answer(question);
+ answer.map(|answer| answer.answer.clone())
+ }
+
+ fn answer_with_password(
+ &self,
+ question: &agama_lib::questions::WithPassword,
+ ) -> (Option, Option) {
+ // use here fact that with password share same matchers as generic one
+ let answer = self.find_answer(&question.base);
+ if let Some(answer) = answer {
+ (Some(answer.answer.clone()), answer.password.clone())
+ } else {
+ (None, None)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use agama_lib::questions::{GenericQuestion, WithPassword};
+
+ use crate::questions::AnswerStrategy;
+
+ use super::*;
+
+ // set of fixtures for test
+ fn get_answers() -> Answers {
+ Answers {
+ answers: vec![
+ Answer {
+ class: Some("without_data".to_string()),
+ data: None,
+ text: None,
+ answer: "Ok".to_string(),
+ password: Some("testing pwd".to_string()), // ignored for generic question
+ },
+ Answer {
+ class: Some("with_data".to_string()),
+ data: Some(HashMap::from([
+ ("data1".to_string(), "value1".to_string()),
+ ("data2".to_string(), "value2".to_string()),
+ ])),
+ text: None,
+ answer: "Maybe".to_string(),
+ password: None,
+ },
+ Answer {
+ class: Some("with_data".to_string()),
+ data: Some(HashMap::from([(
+ "data1".to_string(),
+ "another_value1".to_string(),
+ )])),
+ text: None,
+ answer: "Ok2".to_string(),
+ password: None,
+ },
+ ],
+ }
+ }
+
+ #[test]
+ fn test_class_match() {
+ let answers = get_answers();
+ let question = GenericQuestion {
+ id: 1,
+ class: "without_data".to_string(),
+ text: "JFYI we will kill all bugs during installation.".to_string(),
+ options: vec!["Ok".to_string(), "Cancel".to_string()],
+ default_option: "Cancel".to_string(),
+ data: HashMap::new(),
+ answer: "".to_string(),
+ };
+ assert_eq!(Some("Ok".to_string()), answers.answer(&question));
+ }
+
+ #[test]
+ fn test_no_match() {
+ let answers = get_answers();
+ let question = GenericQuestion {
+ id: 1,
+ class: "non-existing".to_string(),
+ text: "Hard question?".to_string(),
+ options: vec!["Ok".to_string(), "Cancel".to_string()],
+ default_option: "Cancel".to_string(),
+ data: HashMap::new(),
+ answer: "".to_string(),
+ };
+ assert_eq!(None, answers.answer(&question));
+ }
+
+ #[test]
+ fn test_with_password() {
+ let answers = get_answers();
+ let question = GenericQuestion {
+ id: 1,
+ class: "without_data".to_string(),
+ text: "Please provide password for dooms day.".to_string(),
+ options: vec!["Ok".to_string(), "Cancel".to_string()],
+ default_option: "Cancel".to_string(),
+ data: HashMap::new(),
+ answer: "".to_string(),
+ };
+ let with_password = WithPassword {
+ password: "".to_string(),
+ base: question,
+ };
+ let expected = (Some("Ok".to_string()), Some("testing pwd".to_string()));
+ assert_eq!(expected, answers.answer_with_password(&with_password));
+ }
+
+ /// An Answer matches on *data* if all its keys and values are in the GenericQuestion *data*.
+ /// The GenericQuestion can have other *data* keys.
+ #[test]
+ fn test_partial_data_match() {
+ let answers = get_answers();
+ let question = GenericQuestion {
+ id: 1,
+ class: "with_data".to_string(),
+ text: "Hard question?".to_string(),
+ options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()],
+ default_option: "Cancel".to_string(),
+ data: HashMap::from([
+ ("data1".to_string(), "value1".to_string()),
+ ("data2".to_string(), "value2".to_string()),
+ ("data3".to_string(), "value3".to_string()),
+ ]),
+ answer: "".to_string(),
+ };
+ assert_eq!(Some("Maybe".to_string()), answers.answer(&question));
+ }
+
+ #[test]
+ fn test_full_data_match() {
+ let answers = get_answers();
+ let question = GenericQuestion {
+ id: 1,
+ class: "with_data".to_string(),
+ text: "Hard question?".to_string(),
+ options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()],
+ default_option: "Cancel".to_string(),
+ data: HashMap::from([
+ ("data1".to_string(), "another_value1".to_string()),
+ ("data2".to_string(), "value2".to_string()),
+ ("data3".to_string(), "value3".to_string()),
+ ]),
+ answer: "".to_string(),
+ };
+ assert_eq!(Some("Ok2".to_string()), answers.answer(&question));
+ }
+
+ #[test]
+ fn test_no_data_match() {
+ let answers = get_answers();
+ let question = GenericQuestion {
+ id: 1,
+ class: "with_data".to_string(),
+ text: "Hard question?".to_string(),
+ options: vec!["Ok2".to_string(), "Maybe".to_string(), "Cancel".to_string()],
+ default_option: "Cancel".to_string(),
+ data: HashMap::from([
+ ("data1".to_string(), "different value".to_string()),
+ ("data2".to_string(), "value2".to_string()),
+ ("data3".to_string(), "value3".to_string()),
+ ]),
+ answer: "".to_string(),
+ };
+ assert_eq!(None, answers.answer(&question));
+ }
+
+ // A "universal answer" with unspecified class+text+data is possible
+ #[test]
+ fn test_universal_match() {
+ let answers = Answers {
+ answers: vec![Answer {
+ class: None,
+ text: None,
+ data: None,
+ answer: "Yes".into(),
+ password: None,
+ }],
+ };
+ let question = GenericQuestion {
+ id: 1,
+ class: "without_data".to_string(),
+ text: "JFYI we will kill all bugs during installation.".to_string(),
+ options: vec!["Ok".to_string(), "Cancel".to_string()],
+ default_option: "Cancel".to_string(),
+ data: HashMap::new(),
+ answer: "".to_string(),
+ };
+ assert_eq!(Some("Yes".to_string()), answers.answer(&question));
+ }
+
+ #[test]
+ fn test_loading_yaml() {
+ let file = r#"
+ answers:
+ - class: "without_data"
+ answer: "OK"
+ - class: "with_data"
+ data:
+ testk: testv
+ testk2: testv2
+ answer: "Cancel"
+ "#;
+ let result: Answers = serde_yaml::from_str(file).expect("failed to load yaml string");
+ assert_eq!(result.answers.len(), 2);
+ }
+}
diff --git a/rust/agama-lib/src/proxies.rs b/rust/agama-lib/src/proxies.rs
index d6678c0701..9d6371eac0 100644
--- a/rust/agama-lib/src/proxies.rs
+++ b/rust/agama-lib/src/proxies.rs
@@ -111,14 +111,21 @@ trait Locale1 {
fn set_vconsole_keyboard(&self, value: &str) -> zbus::Result<()>;
}
-#[dbus_proxy(interface = "org.opensuse.Agama.Questions1", assume_defaults = true)]
+#[dbus_proxy(
+ interface = "org.opensuse.Agama.Questions1",
+ default_service = "org.opensuse.Agama.Questions1",
+ default_path = "/org/opensuse/Agama/Questions1"
+)]
trait Questions1 {
+ /// AddAnswerFile method
+ fn add_answer_file(&self, path: &str) -> zbus::Result<()>;
+
/// Delete method
fn delete(&self, question: &zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>;
/// New method
#[dbus_proxy(name = "New")]
- fn new_generic(
+ fn new_quetion(
&self,
class: &str,
text: &str,
@@ -137,6 +144,8 @@ trait Questions1 {
data: std::collections::HashMap<&str, &str>,
) -> zbus::Result;
- /// UseDefaultAnswer method
- fn use_default_answer(&self) -> zbus::Result<()>;
+ /// Interactive property
+ #[dbus_proxy(property)]
+ fn interactive(&self) -> zbus::Result;
+ fn set_interactive(&self, value: bool) -> zbus::Result<()>;
}
diff --git a/rust/agama-lib/src/questions.rs b/rust/agama-lib/src/questions.rs
index b5e6b75970..34094b4371 100644
--- a/rust/agama-lib/src/questions.rs
+++ b/rust/agama-lib/src/questions.rs
@@ -1,11 +1,11 @@
-use std::collections::HashMap;
+//! Data model for Agama questions
-/// module holdings data model for agama questions
+use std::collections::HashMap;
/// Basic generic question that fits question without special needs
#[derive(Clone, Debug)]
pub struct GenericQuestion {
- /// numeric id used to indetify question on dbus
+ /// numeric id used to identify question on D-Bus
pub id: u32,
/// class of questions. Similar kinds of questions share same class.
/// It is dot separated list of elements. Examples are
@@ -74,11 +74,13 @@ impl GenericQuestion {
/// mixins arise to convert it to Question Struct that have optional mixins
/// inside like
///
+/// ```no_compile
/// struct Question {
/// base: GenericQuestion,
/// with_password: Option,
/// another_mixin: Option
/// }
+/// ```
///
/// This way all handling code can check if given mixin is used and
/// act appropriate.
diff --git a/rust/package/agama-cli.changes b/rust/package/agama-cli.changes
index 20902ea8d0..15f54b5eb1 100644
--- a/rust/package/agama-cli.changes
+++ b/rust/package/agama-cli.changes
@@ -1,3 +1,11 @@
+-------------------------------------------------------------------
+Wed Jul 26 11:08:09 UTC 2023 - Josef Reidinger
+
+- CLI: add to "questions" command "answers" subcommand to set
+ file with predefined answers
+- dbus-server: add "AddAnswersFile" method to Questions service
+ (gh#openSUSE/agama#669)
+
-------------------------------------------------------------------
Tue Jul 18 13:32:04 UTC 2023 - Josef Reidinger