Skip to content

Commit

Permalink
add(edit): toml task editing mode
Browse files Browse the repository at this point in the history
  • Loading branch information
Nukesor committed Feb 1, 2025
1 parent fbca708 commit a5e159a
Show file tree
Hide file tree
Showing 8 changed files with 172 additions and 29 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Upon updating Pueue and restarting the daemon, the previous state will be wiped,
- Change default log level from error to warning [#562](https://github.com/Nukesor/pueue/issues/562).
- Bumped MSRV to 1.70.
- **Breaking**: Redesigned task editing process [#553](https://github.com/Nukesor/pueue/issues/553).
Pueue now allows editing all properties a task in one editor session. There're two modes to do so: `toml` and `files`.

### Add

Expand Down
35 changes: 31 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ repository = "https://github.com/nukesor/pueue"
rust-version = "1.70"

[workspace.dependencies]
# Chrono version is hard pinned to a specific version.
# See https://github.com/Nukesor/pueue/issues/534
anyhow = "1"
better-panic = "0.3"
chrono = { version = "0.4", features = ["serde"] }
Expand Down
1 change: 1 addition & 0 deletions pueue/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ snap.workspace = true
strum.workspace = true
tempfile = "3"
tokio.workspace = true
toml = "0.8"

[dev-dependencies]
anyhow.workspace = true
Expand Down
110 changes: 89 additions & 21 deletions pueue/src/client/commands/edit.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::env;
use std::fs::{create_dir, read_to_string, File};
use std::io::Write;
Expand Down Expand Up @@ -33,46 +34,113 @@ pub async fn edit(
// In case we don't receive an EditResponse, something went wrong.
// Return the response to the parent function and let the client handle it
// by the generic message handler.
let Message::EditResponse(mut editable_tasks) = init_response else {
let Message::EditResponse(editable_tasks) = init_response else {
return Ok(init_response);
};

let edit_result = edit_tasks(settings, &mut editable_tasks);
let task_ids: Vec<usize> = editable_tasks.iter().map(|task| task.id).collect();
let result = edit_tasks(settings, editable_tasks);

// Any error while editing will result in the client aborting the editing process.
// However, as the daemon moves tasks that're edited into the `Locked` state, we cannot simply
// exit the client. We rather have to notify the daemon that the editing process was interrupted.
// In the following, we notify the daemon of any errors, so it can restore the tasks to
// their previous state.
if let Err(error) = edit_result {
eprintln!("Encountered an error while editing. Trying to restore the task's status.");
// Notify the daemon that something went wrong.
let task_ids = editable_tasks.iter().map(|task| task.id).collect();
let edit_message = Message::EditRestore(task_ids);
send_message(edit_message, stream).await?;

let response = receive_message(stream).await?;
match response {
Message::Failure(message) | Message::Success(message) => {
eprintln!("{message}");
}
_ => eprintln!("Received unknown response: {response:?}"),
};

return Err(error);
}
let editable_tasks = match result {
Ok(editable_tasks) => editable_tasks,
Err(error) => {
eprintln!("Encountered an error while editing. Trying to restore the task's status.");
// Notify the daemon that something went wrong.
let edit_message = Message::EditRestore(task_ids);
send_message(edit_message, stream).await?;

let response = receive_message(stream).await?;
match response {
Message::Failure(message) | Message::Success(message) => {
eprintln!("{message}");
}
_ => eprintln!("Received unknown response: {response:?}"),
};

return Err(error);
}
};

// Create a new message with the edited properties.
send_message(Message::Edit(editable_tasks), stream).await?;

Ok(receive_message(stream).await?)
}

pub fn edit_tasks(settings: &Settings, editable_tasks: &mut [EditableTask]) -> Result<()> {
/// This is a small generic wrapper around the editing logic.
///
/// There're two different editing modes in Pueue, one file based and on toml based.
/// Call the respective function based on the editing mode.
pub fn edit_tasks(
settings: &Settings,
editable_tasks: Vec<EditableTask>,
) -> Result<Vec<EditableTask>> {
// Create the temporary directory that'll be used for all edits.
let temp_dir = tempdir().context("Failed to create temporary directory for edtiting.")?;
let temp_dir_path = temp_dir.path();

match settings.client.edit_mode {
pueue_lib::settings::EditMode::Toml => {
edit_tasks_with_toml(settings, editable_tasks, temp_dir_path)
}
pueue_lib::settings::EditMode::Files => {
edit_tasks_with_folder(settings, editable_tasks, temp_dir_path)
}
}
}

/// This editing mode creates a temporary folder that contains a single `tasks.toml` file.
///
/// This file contains all tasks to be edited with their respective properties.
/// While this is very convenient, users must make sure to not malform the content and respect toml
/// based escaping as not doing so could lead to deserialization errors or broken/misbehaving
/// task commands.
pub fn edit_tasks_with_toml(
settings: &Settings,
editable_tasks: Vec<EditableTask>,
temp_dir_path: &Path,
) -> Result<Vec<EditableTask>> {
// Convert to map for nicer representation and serialize to toml.
// The keys of the map must be strings for toml to work.
let map: BTreeMap<String, EditableTask> = BTreeMap::from_iter(
editable_tasks
.into_iter()
.map(|task| (task.id.to_string(), task)),
);
let toml = toml::to_string(&map)
.map_err(|err| Error::Generic(format!("\nFailed to serialize tasks to toml:\n{err}")))?;
let temp_file_path = temp_dir_path.join("tasks.toml");

// Write the file to disk and open it with the editor.
std::fs::write(&temp_file_path, toml).map_err(|err| {
Error::IoPathError(temp_file_path.clone(), "creating temporary file", err)
})?;
run_editor(settings, &temp_file_path)?;

// Read the data back from disk into the map and deserialize it back into a map.
let content = read_to_string(&temp_file_path)
.map_err(|err| Error::IoPathError(temp_file_path.clone(), "reading temporary file", err))?;
let map: BTreeMap<String, EditableTask> = toml::from_str(&content)
.map_err(|err| Error::Generic(format!("\nFailed to deserialize tasks to toml:\n{err}")))?;

Ok(map.into_values().collect())
}

/// This editing mode creates a temporary folder in which one subfolder is created for each task
/// that should be edited.
/// Those task folders then contain a single file for each of the task's editable properties.
/// This approach allows one to edit properties without having to worry about potential file
/// formats or other shennanigans.
pub fn edit_tasks_with_folder(
settings: &Settings,
mut editable_tasks: Vec<EditableTask>,
temp_dir_path: &Path,
) -> Result<Vec<EditableTask>> {
for task in editable_tasks.iter() {
task.create_temp_dir(temp_dir_path)?
}
Expand All @@ -84,7 +152,7 @@ pub fn edit_tasks(settings: &Settings, editable_tasks: &mut [EditableTask]) -> R
task.read_temp_dir(temp_dir_path)?
}

Ok(())
Ok(editable_tasks)
}

/// Open the folder that contains all files for editing in the user's `$EDITOR`.
Expand Down
2 changes: 1 addition & 1 deletion pueue/src/client/commands/restart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub async fn restart(
// If the tasks should be edited, edit them in one go.
if edit {
let mut editable_tasks: Vec<EditableTask> = tasks.iter().map(EditableTask::from).collect();
edit_tasks(settings, &mut editable_tasks)?;
editable_tasks = edit_tasks(settings, editable_tasks)?;

// Now merge the edited properties back into the tasks.
// We simply zip the task and editable task vectors, as we know that they have the same
Expand Down
36 changes: 35 additions & 1 deletion pueue/tests/client/integration/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::client::helper::*;

/// Test that editing a task without any flags only updates the command.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn edit_task_default() -> Result<()> {
async fn edit_task_directory() -> Result<()> {
let daemon = daemon().await?;
let shared = &daemon.settings.shared;

Expand Down Expand Up @@ -159,3 +159,37 @@ async fn fail_to_edit_task() -> Result<()> {

Ok(())
}

/// Test that editing a task without any flags only updates the command.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn edit_task_toml() -> Result<()> {
let daemon = daemon().await?;
let shared = &daemon.settings.shared;

// Create a stashed message which we'll edit later on.
let mut message = create_add_message(shared, "this is a test");
message.stashed = true;
send_message(shared, message)
.await
.context("Failed to to add stashed task.")?;

// Update the task's command by piping a string to the temporary file.
let mut envs = HashMap::new();
envs.insert(
"EDITOR",
"echo '[0]\nid = 0\ncommand = \"expected command string\"\npath = \"/tmp\"\npriority = 0' > ${PUEUE_EDIT_PATH} ||",
);
run_client_command_with_env(shared, &["edit", "0"], envs)?;

// Make sure that both the command has been updated.
let state = get_state(shared).await?;
let task = state.tasks.get(&0).unwrap();
assert_eq!(task.command, "expected command string");
assert_eq!(task.path.to_string_lossy(), "/tmp");

// All other properties should be unchanged.
assert_eq!(task.label, None);
assert_eq!(task.priority, 0);

Ok(())
}
14 changes: 14 additions & 0 deletions pueue_lib/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ pub struct Shared {
pub shared_secret_path: Option<PathBuf>,
}

/// The mode in which the client should edit tasks.
#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize, Default)]
pub enum EditMode {
/// Edit by having one large file with all tasks to be edited inside at the same time
#[default]
Toml,
/// Edit by creating a folder for each task to be edited, where each property is a single file.
Files,
}

/// All settings which are used by the client
#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)]
pub struct Client {
Expand All @@ -97,6 +107,9 @@ pub struct Client {
/// Whether the client should show a confirmation question on potential dangerous actions.
#[serde(default = "Default::default")]
pub show_confirmation_questions: bool,
/// Whether the client should show a confirmation question on potential dangerous actions.
#[serde(default = "Default::default")]
pub edit_mode: EditMode,
/// Whether aliases specified in `pueue_aliases.yml` should be expanded in the `pueue status`
/// or shown in their short form.
#[serde(default = "Default::default")]
Expand Down Expand Up @@ -173,6 +186,7 @@ impl Default for Client {
read_local_logs: true,
show_confirmation_questions: false,
show_expanded_aliases: false,
edit_mode: Default::default(),
dark_mode: false,
max_status_lines: None,
status_time_format: default_status_time_format(),
Expand Down

0 comments on commit a5e159a

Please sign in to comment.