Implement TUI search and mutation workflows

This commit is contained in:
Hermes Agent
2026-08-10 10:11:34 +00:00
parent c86ea9eb0e
commit ce3e4a9f79
13 changed files with 1195 additions and 93 deletions

View File

@@ -8,6 +8,7 @@ pub mod app;
pub mod command;
pub mod editor;
pub mod runtime;
pub mod search;
pub mod sidebar;
pub mod terminal;
pub mod ui;
@@ -263,7 +264,29 @@ fn apply_app_effect(
app.report_status(format!("locked after secure-store cleanup failed: {error}"));
}
}
AppEffect::RunCommand(_) | AppEffect::OpenWorkflow(_) | AppEffect::None => {}
AppEffect::RunCommand(ironstorage::command::CommandRequest::Find(request)) => {
if let Some(config) = app.config().cloned() {
let query = request.terms.join(" ");
let token = app.begin_latest_request();
executor.submit(token, move || {
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
let results = ironstorage::read::VaultReader::new(&repository, &keys)
.find(&request.terms)
.map_err(|error| error.to_string())?;
Ok(AsyncPayload::Found { query, results })
});
}
}
AppEffect::RunCommand(request) => {
app.report_status(format!(
"{} is not implemented by this terminal workflow yet",
crate::command::operation_name(&request)
));
}
AppEffect::OpenWorkflow(_) | AppEffect::None => {}
}
}
@@ -373,7 +396,10 @@ fn execute_workflow(
) -> Result<WorkflowSuccess, String> {
use ironstorage::{
generate::{GeneratorConfig, PasswordGenerator},
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity},
git::{
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
},
mutation::TreeMutator,
recipient::RecipientPolicyManager,
write::VaultWriter,
};
@@ -383,6 +409,9 @@ fn execute_workflow(
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
let identity = GitIdentity::ironstorage();
let mut selection = None;
let mut grep = None;
let mut refresh_tree = true;
let (entry, mut status) = match submission {
WorkflowSubmission::Init(request) => {
let directory = request.path.as_deref().unwrap_or_default();
@@ -428,13 +457,81 @@ fn execute_workflow(
format!("Generated password for {entry}"),
)
}
WorkflowSubmission::Grep(request) => {
let results = ironstorage::read::VaultReader::new(&repository, &keys)
.grep(&request, provider)
.map_err(|error| error.to_string())?;
let count = results.entries().len();
grep = Some(results);
refresh_tree = false;
(
None,
if count == 0 {
"Decrypted search completed with no matches".to_owned()
} else {
format!("Decrypted search found {count} matching entries")
},
)
}
WorkflowSubmission::Remove(request) => {
let target = request.entry.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.remove(
&request,
ironstorage::write::OverwriteDecision::Allow,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(
None,
format!(
"Removed {target} ({} encrypted entries)",
outcome.entries().len()
),
)
}
WorkflowSubmission::Move { request, overwrite } => {
let source = request.source.clone();
let destination = request.destination.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.move_tree(&request, overwrite, None, provider, &mut committer)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Moved {source} to {destination}"))
}
WorkflowSubmission::Copy { request, overwrite } => {
let source = request.source.clone();
let destination = request.destination.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.copy(&request, overwrite, None, provider, &mut committer)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Copied {source} to {destination}"))
}
};
let tree = ironstorage::read::VaultReader::new(&repository, &keys)
.list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| {
status.push_str(&format!("; sidebar refresh failed: {error}"));
let tree = refresh_tree
.then(|| {
ironstorage::read::VaultReader::new(&repository, &keys)
.list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| {
status.push_str(&format!("; sidebar refresh failed: {error}"));
})
.ok()
})
.ok();
.flatten();
let document = entry
.as_deref()
.map(|entry| {
@@ -451,8 +548,10 @@ fn execute_workflow(
.flatten();
Ok(WorkflowSuccess {
tree,
selection,
entry,
document,
grep,
status,
})
}
@@ -567,7 +666,8 @@ mod tests {
use ironstorage::{
command::{
GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest,
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
},
crypto::{KeyInfo, SecretProvider, SecretProviderError},
repository::SecretBytes,
@@ -575,7 +675,7 @@ mod tests {
};
use super::*;
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from};
use crate::{action::Action, editor::EntryEditor, viewer::test_support::fixture_document_from};
#[test]
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
@@ -646,6 +746,14 @@ mod tests {
}
}
struct UnavailableSecrets;
impl SecretProvider for UnavailableSecrets {
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
Err(SecretProviderError::Unavailable)
}
}
#[test]
fn write_workflows_use_storage_rules_refresh_and_commit_in_isolation() {
let temporary = tempfile::tempdir().expect("temporary workflow store");
@@ -790,6 +898,259 @@ mod tests {
);
}
#[test]
fn decrypted_search_handles_cancel_empty_failure_and_result_navigation() {
let temporary = tempfile::tempdir().expect("temporary search store");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let store = temporary.path().join("store");
copy_directory(&fixtures.join("stores/basic"), &store);
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
store,
fixtures.join("keys"),
),
)
.expect("write config");
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
let mut app = App::new();
app.sidebar_mut()
.replace_tree(&load_tree(&config).expect("tree"));
let repository =
ironstorage::repository::Repository::open(config.vault()).expect("repository");
let keys =
ironstorage::crypto::KeyStore::load(config.key_material()).expect("key material");
let terms = vec!["personal".to_owned()];
let find = ironstorage::read::VaultReader::new(&repository, &keys)
.find(&terms)
.expect("name find");
let token = app.begin_request();
assert_eq!(
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::Found {
query: "personal".to_owned(),
results: find,
}),
}),
crate::app::ResultDisposition::Applied
);
assert_eq!(app.sidebar().filter_query(), "personal");
app.sidebar_mut().next_match();
assert_eq!(
app.sidebar().selected().map(|selected| selected.path()),
Some("email/personal")
);
app.sidebar_mut().clear_filter_results();
assert!(matches!(app.dispatch(Action::Grep), AppEffect::None));
assert_eq!(app.mode(), crate::app::Mode::Dialog);
app.handle_workflow_input(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.mode(), crate::app::Mode::Browser);
assert!(app.status().contains("cancelled"));
let request = |pattern: &str| GrepRequest {
pattern: pattern.to_owned(),
ignore_case: false,
invert_match: false,
line_number: true,
fixed_strings: true,
};
let empty = execute_workflow(
&config,
WorkflowSubmission::Grep(request("not-present-anywhere")),
&mut FixtureSecrets,
)
.expect("empty grep");
assert!(
empty
.grep
.as_ref()
.is_some_and(|results| results.is_empty())
);
assert!(empty.status.contains("no matches"));
let failure = execute_workflow(
&config,
WorkflowSubmission::Grep(request("password")),
&mut UnavailableSecrets,
)
.expect_err("decryption must need a secret");
assert!(!failure.contains("fixture-alice-passphrase"));
let found = execute_workflow(
&config,
WorkflowSubmission::Grep(request("password")),
&mut FixtureSecrets,
)
.expect("matching grep");
let token = app.begin_request();
assert_eq!(
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::WorkflowFinished(Ok(Box::new(found)))),
}),
crate::app::ResultDisposition::Applied
);
let first = app
.grep_view()
.and_then(crate::search::GrepView::selected_entry)
.expect("first result")
.path()
.to_string();
app.dispatch(Action::Next);
let second = app
.grep_view()
.and_then(crate::search::GrepView::selected_entry)
.expect("second result")
.path()
.to_string();
assert_ne!(first, second);
assert!(matches!(
app.dispatch(Action::Activate),
AppEffect::AuthenticateEntry(ref entry) if entry == &second
));
assert_eq!(
app.sidebar().selected().map(|selected| selected.path()),
Some(second.as_str())
);
assert!(app.grep_view().is_none());
}
#[test]
fn existing_tree_mutations_commit_refresh_and_preserve_failed_collisions() {
let temporary = tempfile::tempdir().expect("temporary mutation store");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let store = temporary.path().join("store");
copy_directory(&fixtures.join("stores/basic"), &store);
let repository = ironstorage::repository::Repository::open(&store).expect("repository");
let identity = ironstorage::git::GitIdentity::ironstorage();
let git = ironstorage::git::GitRepository::init(&repository, identity.clone())
.expect("initialize git");
let initial_commits = git.log(None).expect("initial log").len();
drop(git);
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
store,
fixtures.join("keys"),
),
)
.expect("write config");
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
let mut secrets = FixtureSecrets;
let copied = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "email/personal".to_owned(),
destination: "team/copied".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("copy across recipient boundary");
assert_eq!(copied.selection, Some(("team/copied".to_owned(), false)));
assert!(copied.tree.is_some());
assert_eq!(
fixture_document_from(&store, "team/copied")
.password()
.expect("copied password")
.value(),
b"correct horse fixture"
);
let collision_ciphertext =
fs::read(store.join("team/service.gpg")).expect("collision ciphertext");
let git =
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
let commits_before_collision = git.log(None).expect("log").len();
drop(git);
let collision = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "email/personal".to_owned(),
destination: "team/service".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
);
assert!(collision.is_err());
assert_eq!(
fs::read(store.join("team/service.gpg")).expect("unchanged collision"),
collision_ciphertext
);
let git =
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
assert_eq!(git.log(None).expect("log").len(), commits_before_collision);
drop(git);
let moved = execute_workflow(
&config,
WorkflowSubmission::Move {
request: MoveRequest {
source: "team/copied".to_owned(),
destination: "shared/".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("move using explicit destination directory semantics");
assert_eq!(moved.selection, Some(("shared/copied".to_owned(), false)));
assert!(store.join("shared/copied.gpg").is_file());
assert!(!store.join("team/copied.gpg").exists());
let copied_directory = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "team".to_owned(),
destination: "archive".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("copy folder");
assert_eq!(
copied_directory.selection,
Some(("archive".to_owned(), true))
);
assert!(store.join("archive/service.gpg").is_file());
execute_workflow(
&config,
WorkflowSubmission::Remove(RemoveRequest {
entry: "archive".to_owned(),
recursive: true,
force: false,
}),
&mut secrets,
)
.expect("recursive remove");
assert!(!store.join("archive").exists());
let git = ironstorage::git::GitRepository::open(&repository, identity).expect("open git");
assert_eq!(git.log(None).expect("log").len(), initial_commits + 4);
}
fn copy_directory(source: &Path, destination: &Path) {
fs::create_dir_all(destination).expect("create destination");
for entry in fs::read_dir(source).expect("read source") {