From a0e27ce3892604a68475ea2b14ee811a4fa59fa7 Mon Sep 17 00:00:00 2001 From: Ryan Marganti Date: Sun, 15 Mar 2026 22:28:00 -0400 Subject: [PATCH 1/3] refactor init as a sub-command --- src/config.rs | 41 +++++++++++++++++++++++------------------ src/job/init.rs | 6 ++++++ src/job/mod.rs | 31 +++++++++++-------------------- src/main.rs | 14 ++++++++++---- 4 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/config.rs b/src/config.rs index e3540ac..daa3e52 100644 --- a/src/config.rs +++ b/src/config.rs @@ -33,12 +33,27 @@ pub enum ConfigError { /// Synchronize secrets between different sources #[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] +#[command(author, version, about, long_about = None, args_conflicts_with_subcommands = true)] pub struct Args { /// Config file to use for presets - #[arg(short, long, default_value_t = String::from(DEFAULT_CONFIG))] + #[arg(short, long, default_value_t = String::from(DEFAULT_CONFIG), global = true)] pub config: String, + #[command(subcommand)] + pub command: Option, + + #[command(flatten)] + pub sync_args: SyncArgs, +} + +#[derive(clap::Subcommand, Debug)] +pub enum Command { + /// Initialize a new config file + Init, +} + +#[derive(clap::Args, Debug)] +pub struct SyncArgs { /// From where to pull secrets #[arg(short, long)] pub from: Option, @@ -55,28 +70,18 @@ pub struct Args { pub preset: Option, } -impl Args { +impl SyncArgs { pub fn validate(&self, config: &Config) -> Result<(), ConfigError> { - // If preset is provided, ensure it exists + // If using a preset, ensure it exists. if let Some(ref preset_name) = self.preset { - if preset_name != "init" && !config.presets.contains_key(preset_name) { + if !config.presets.contains_key(preset_name) { return Err(ConfigError::PresetNotFound(preset_name.clone())); } } - if self.diff { - // Diff mode: need both sides (preset supplies both, or both --from and --to) - let has_from = self.preset.is_some() || self.from.is_some(); - let has_to = self.preset.is_some() || self.to.is_some(); - - if !has_from || !has_to { - return Err(ConfigError::MissingArguments); - } - } else { - // Sync mode: need both sides - if self.preset.is_none() && (self.from.is_none() || self.to.is_none()) { - return Err(ConfigError::MissingArguments); - } + // If not using a preset, both --to and --from are required. + if self.preset.is_none() && (self.from.is_none() || self.to.is_none()) { + return Err(ConfigError::MissingArguments); } Ok(()) diff --git a/src/job/init.rs b/src/job/init.rs index 1c5a5a4..92ffd0e 100644 --- a/src/job/init.rs +++ b/src/job/init.rs @@ -20,6 +20,12 @@ pub enum InitError { /// This job is used to create a default config file. pub struct InitJob {} +impl InitJob { + pub fn new() -> Self { + Self {} + } +} + impl Job for InitJob { /// Write the default config contents to the default config file. fn run(&self) -> anyhow::Result<()> { diff --git a/src/job/mod.rs b/src/job/mod.rs index f3b73e5..5a36fd6 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -1,37 +1,28 @@ +use crate::config::SyncArgs; use crate::sources::{Source, SourceCreateError}; use anyhow::Result; use std::io::IsTerminal; mod diff; -mod init; +pub mod init; mod sync; pub trait Job { fn run(&self) -> Result<()>; } -pub fn new_job( - config: &crate::config::Config, - from: Option, - to: Option, - preset: Option, - diff: bool, -) -> Result> { - if preset == Some("init".to_string()) { - return Ok(Box::new(init::InitJob {})); - } - - let preset_cfg = preset.as_ref().and_then(|p| config.presets.get(p)); +pub fn build_sync_job(config: &crate::config::Config, args: SyncArgs) -> Result> { + let preset_cfg = args.preset.as_ref().and_then(|p| config.presets.get(p)); - if diff { - // In diff mode we never read from stdin or write to stdout automatically. - // Both `from` and `to` are required (enforced by Args::validate). - let from_uri = from + if args.diff { + let from_uri = args + .from .or_else(|| preset_cfg.map(|p| p.from.clone())) .ok_or(SourceCreateError::NoSourceProvided { field: "from" })?; let from_source = ::new(&from_uri)?; - let to_uri = to + let to_uri = args + .to .or_else(|| preset_cfg.map(|p| p.to.clone())) .ok_or(SourceCreateError::NoSourceProvided { field: "to" })?; let to_source = ::new(&to_uri)?; @@ -40,7 +31,7 @@ pub fn new_job( } let from = if std::io::stdin().is_terminal() { - from + args.from } else { Some("std://".to_string()) }; @@ -51,7 +42,7 @@ pub fn new_job( let from = ::new(&from)?; let to = if std::io::stdout().is_terminal() { - to + args.to } else { Some("std://".to_string()) }; diff --git a/src/main.rs b/src/main.rs index 56447c4..01db66c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ -use crate::config::{Args, Config}; +use crate::config::{Args, Command, Config}; +use crate::job::Job; use anyhow::{Context, Result}; use clap::Parser; @@ -9,13 +10,18 @@ mod sources; fn main() -> Result<()> { let args = Args::parse(); + + if let Some(Command::Init) = args.command { + return job::init::InitJob::new().run(); + } + let cfg = Config::from_file(&args.config) .with_context(|| format!("failed to load config from '{}'", args.config))?; - args.validate(&cfg)?; + let sync_args = args.sync_args; + sync_args.validate(&cfg)?; - let job = job::new_job(&cfg, args.from, args.to, args.preset, args.diff) - .context("could not build job")?; + let job = job::build_sync_job(&cfg, sync_args).context("could not build job")?; job.run()?; From a3072ce8f1d1d899c51586d892346bafdece5413 Mon Sep 17 00:00:00 2001 From: Ryan Marganti Date: Sun, 15 Mar 2026 22:43:56 -0400 Subject: [PATCH 2/3] feat: edit command --- Cargo.lock | 192 ++++++++++++++++++++++++++++++++------- Cargo.toml | 1 + src/config.rs | 41 +++++++++ src/job/diff.rs | 20 ++-- src/job/edit.rs | 236 ++++++++++++++++++++++++++++++++++++++++++++++++ src/job/mod.rs | 3 +- src/main.rs | 13 ++- 7 files changed, 459 insertions(+), 47 deletions(-) create mode 100644 src/job/edit.rs diff --git a/Cargo.lock b/Cargo.lock index 8e3b033..cf7cd5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" dependencies = [ - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -81,7 +81,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -322,7 +322,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -356,24 +356,19 @@ checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" [[package]] name = "errno" -version = "0.3.1" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "errno-dragonfly", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "errno-dragonfly" -version = "0.1.2" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" -dependencies = [ - "cc", - "libc", -] +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "flate2" @@ -701,7 +696,7 @@ checksum = "9c66c74d2ae7e79a5a8f7ac924adbe38ee42a859c6539ad869eb51f0b52dc220" dependencies = [ "hermit-abi 0.3.1", "libc", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -712,8 +707,8 @@ checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" dependencies = [ "hermit-abi 0.3.1", "io-lifetimes", - "rustix", - "windows-sys", + "rustix 0.37.27", + "windows-sys 0.48.0", ] [[package]] @@ -877,9 +872,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.153" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "linked-hash-map" @@ -902,6 +897,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ece97ea872ece730aed82664c424eb4c8291e1ff2480247ccf7409044bc6479f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "lock_api" version = "0.4.10" @@ -950,7 +951,7 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1061,7 +1062,7 @@ dependencies = [ "libc", "redox_syscall 0.3.5", "smallvec", - "windows-targets", + "windows-targets 0.48.0", ] [[package]] @@ -1211,7 +1212,7 @@ dependencies = [ "libc", "spin", "untrusted", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1224,8 +1225,21 @@ dependencies = [ "errno", "io-lifetimes", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.3.7", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", ] [[package]] @@ -1308,6 +1322,7 @@ dependencies = [ "openssl-sys", "serde", "serde_json", + "tempfile", "thiserror", "tokio", "ureq", @@ -1464,6 +1479,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.40" @@ -1515,7 +1542,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1850,16 +1877,49 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" dependencies = [ - "windows-targets", + "windows-targets 0.48.0", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -1868,13 +1928,29 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1883,42 +1959,90 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "zeroize" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 56524c1..9d1ba89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ serde_json = "1.0" tokio = { version = "1.28.2", features = ["full"] } ureq = "2.6.2" url = "2.3.1" +tempfile = "3" diff --git a/src/config.rs b/src/config.rs index daa3e52..4996722 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,9 @@ pub enum ConfigError { #[error("must provide either a preset or both --from and --to arguments")] MissingArguments, + + #[error("--target is required when editing a preset")] + EditMissingTarget, } /// Synchronize secrets between different sources @@ -50,6 +53,44 @@ pub struct Args { pub enum Command { /// Initialize a new config file Init, + + /// Edit secrets in-place for a single source + Edit(EditArgs), +} + +#[derive(clap::Args, Debug)] +pub struct EditArgs { + /// Source URI or preset name + pub source: String, + + /// When using a preset, which side to edit (from or to) + #[arg(long)] + pub target: Option, +} + +#[derive(clap::ValueEnum, Clone, Debug)] +pub enum EditTarget { + From, + To, +} + +impl EditArgs { + /// Resolve the source URI. If `source` is a known preset name, `--target` + /// is required to select which side. If it is a raw URI, `--target` must + /// not be provided. + pub fn resolve_uri(&self, config: &Config) -> Result { + if let Some(preset) = config.presets.get(&self.source) { + let target = self.target.as_ref().ok_or(ConfigError::EditMissingTarget)?; + Ok(match target { + EditTarget::From => preset.from.clone(), + EditTarget::To => preset.to.clone(), + }) + } else if self.target.is_some() { + Err(ConfigError::PresetNotFound(self.source.clone())) + } else { + Ok(self.source.clone()) + } + } } #[derive(clap::Args, Debug)] diff --git a/src/job/diff.rs b/src/job/diff.rs index 68827a1..b765ae7 100644 --- a/src/job/diff.rs +++ b/src/job/diff.rs @@ -66,7 +66,7 @@ fn format_entry(key: &str, value: &str) -> String { } #[derive(Clone, Debug)] -enum DiffLine { +pub enum DiffLine { /// Unchanged line surrounding a change Context(String), @@ -78,7 +78,7 @@ enum DiffLine { } /// Build a list of DiffLine representing the differences between from_map and to_map. -fn build_diff_lines( +pub fn build_diff_lines( from_map: &BTreeMap, to_map: &BTreeMap, ) -> (Vec, usize, usize, usize) { @@ -117,13 +117,13 @@ fn build_diff_lines( } /// Group DiffLines into hunks with CONTEXT_LINES of unchanged lines around each change. -struct Hunk { +pub struct Hunk { old_start: usize, new_start: usize, - lines: Vec, + pub lines: Vec, } -fn build_hunks(diff_lines: &[DiffLine]) -> Vec { +pub fn build_hunks(diff_lines: &[DiffLine]) -> Vec { // 1. Find indices of all non-context lines let change_indices: Vec = diff_lines .iter() @@ -208,18 +208,18 @@ fn build_hunks(diff_lines: &[DiffLine]) -> Vec { } /// Helper for printing diffs with optional color support. -struct DiffPrinter { +pub struct DiffPrinter { use_color: bool, } impl DiffPrinter { - fn new() -> Self { + pub fn new() -> Self { Self { use_color: std::io::stdout().is_terminal(), } } - fn print_header(&self, uri: &str) { + pub fn print_header(&self, uri: &str) { if self.use_color { println!("\x1b[1m--- a/{uri}\x1b[0m"); println!("\x1b[1m+++ b/{uri}\x1b[0m"); @@ -229,7 +229,7 @@ impl DiffPrinter { } } - fn print_hunk_header(&self, hunk: &Hunk) { + pub fn print_hunk_header(&self, hunk: &Hunk) { let old_count = hunk .lines .iter() @@ -251,7 +251,7 @@ impl DiffPrinter { } } - fn print_line(&self, line: &DiffLine) { + pub fn print_line(&self, line: &DiffLine) { match line { DiffLine::Context(text) => println!(" {text}"), DiffLine::Remove(text) => { diff --git a/src/job/edit.rs b/src/job/edit.rs new file mode 100644 index 0000000..748ab3a --- /dev/null +++ b/src/job/edit.rs @@ -0,0 +1,236 @@ +use crate::secrets::Secrets; +use crate::sources::Source; +use anyhow::{Context, Result}; +use std::io::{BufRead, Write}; + +use super::diff::{build_diff_lines, build_hunks, DiffPrinter}; + +#[derive(Debug, thiserror::Error)] +pub enum EditError { + #[error("unable to create temporary file")] + CreateTempFile(#[source] std::io::Error), + + #[error("unable to read temporary file")] + ReadTempFile(#[source] std::io::Error), + + #[error("could not determine an editor; set $VISUAL or $EDITOR")] + NoEditor, + + #[error("editor exited with non-zero status")] + EditorFailed, + + #[error("unable to launch editor '{0}'")] + LaunchEditor(String, #[source] std::io::Error), +} + +pub struct EditJob { + source: Box, + uri: String, +} + +impl EditJob { + pub fn new(uri: &str) -> Result { + let source = ::new(uri)?; + Ok(Self { + source, + uri: uri.to_string(), + }) + } +} + +impl super::Job for EditJob { + fn run(&self) -> Result<()> { + let original = self + .source + .read_secrets() + .context("unable to read secrets from source")?; + + let original_content = serialize_secrets(&original)?; + + let temp_file = tempfile::Builder::new() + .prefix(".scrtsync-edit-") + .suffix(".env") + .tempfile() + .map_err(EditError::CreateTempFile)?; + + std::fs::write(temp_file.path(), &original_content).map_err(EditError::ReadTempFile)?; + + let edited = loop { + open_editor(temp_file.path())?; + + let edited_bytes = std::fs::read(temp_file.path()).map_err(EditError::ReadTempFile)?; + + if edited_bytes == original_content { + eprintln!("No changes detected, aborting."); + return Ok(()); + } + + if edited_bytes.is_empty() || edited_bytes.iter().all(|b| b.is_ascii_whitespace()) { + eprintln!("Empty file, aborting."); + return Ok(()); + } + + match Secrets::from_reader(&mut edited_bytes.as_slice()) { + Ok(secrets) => break secrets, + Err(e) => { + eprintln!("Parse error: {e}"); + eprintln!("Re-opening editor..."); + continue; + } + } + }; + + let (diff_lines, added, changed, removed) = + build_diff_lines(&edited.content, &original.content); + + if added == 0 && changed == 0 && removed == 0 { + eprintln!("No changes detected, aborting."); + return Ok(()); + } + + let hunks = build_hunks(&diff_lines); + let printer = DiffPrinter::new(); + + printer.print_header(&self.uri); + for hunk in &hunks { + printer.print_hunk_header(hunk); + for line in &hunk.lines { + printer.print_line(line); + } + } + + eprintln!("\n{added} added, {changed} changed, {removed} removed"); + + if !confirm("Apply changes?")? { + eprintln!("Aborted."); + return Ok(()); + } + + self.source + .write_secrets(&edited) + .context("unable to write secrets to source")?; + + eprintln!("Changes applied."); + + Ok(()) + } +} + +/// Serialize secrets to bytes for comparison. +fn serialize_secrets(secrets: &Secrets) -> Result> { + let mut buf = Vec::new(); + secrets + .to_writer(&mut buf) + .context("unable to serialize secrets")?; + Ok(buf) +} + +/// Resolve the user's preferred editor. +fn resolve_editor() -> Result { + std::env::var("VISUAL") + .or_else(|_| std::env::var("EDITOR")) + .or_else(|_| Ok("vi".to_string())) + .map_err(|_: std::env::VarError| EditError::NoEditor) +} + +/// Open the given file in the user's editor and wait for it to exit. +fn open_editor(path: &std::path::Path) -> Result<()> { + let editor = resolve_editor()?; + let status = std::process::Command::new(&editor) + .arg(path) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| EditError::LaunchEditor(editor.clone(), e))?; + + if !status.success() { + return Err(EditError::EditorFailed.into()); + } + + Ok(()) +} + +/// Prompt the user with `" [y/N] "` on stderr, read from stdin. +/// Returns true only for `y` or `Y`. +fn confirm(message: &str) -> Result { + eprint!("{message} [y/N] "); + std::io::stderr().flush()?; + + let mut input = String::new(); + std::io::stdin().lock().read_line(&mut input)?; + + Ok(input.trim().eq_ignore_ascii_case("y")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn make_secrets(pairs: &[(&str, &str)]) -> Secrets { + let map: BTreeMap = pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + Secrets::from(map) + } + + #[test] + fn identical_content_detected_as_no_change() { + let secrets = make_secrets(&[("FOO", "bar"), ("BAZ", "qux")]); + let content = serialize_secrets(&secrets).unwrap(); + + let reparsed = Secrets::from_reader(&mut content.as_slice()).unwrap(); + let (_, added, changed, removed) = build_diff_lines(&reparsed.content, &secrets.content); + + assert_eq!(added, 0); + assert_eq!(changed, 0); + assert_eq!(removed, 0); + } + + #[test] + fn empty_content_is_detected() { + let content = b""; + assert!(content.is_empty()); + + let whitespace_content = b" \n \n "; + assert!(whitespace_content.iter().all(|b| b.is_ascii_whitespace())); + } + + #[test] + fn modified_content_detected_as_changed() { + let original = make_secrets(&[("FOO", "bar"), ("BAZ", "qux")]); + let edited = make_secrets(&[("FOO", "changed"), ("BAZ", "qux")]); + + let (_, added, changed, removed) = build_diff_lines(&edited.content, &original.content); + + assert_eq!(added, 0); + assert_eq!(changed, 1); + assert_eq!(removed, 0); + } + + #[test] + fn added_key_detected() { + let original = make_secrets(&[("FOO", "bar")]); + let edited = make_secrets(&[("FOO", "bar"), ("NEW", "value")]); + + let (_, added, changed, removed) = build_diff_lines(&edited.content, &original.content); + + assert_eq!(added, 1); + assert_eq!(changed, 0); + assert_eq!(removed, 0); + } + + #[test] + fn removed_key_detected() { + let original = make_secrets(&[("FOO", "bar"), ("BAZ", "qux")]); + let edited = make_secrets(&[("FOO", "bar")]); + + let (_, added, changed, removed) = build_diff_lines(&edited.content, &original.content); + + assert_eq!(added, 0); + assert_eq!(changed, 0); + assert_eq!(removed, 1); + } +} diff --git a/src/job/mod.rs b/src/job/mod.rs index 5a36fd6..7ab6ba6 100644 --- a/src/job/mod.rs +++ b/src/job/mod.rs @@ -3,7 +3,8 @@ use crate::sources::{Source, SourceCreateError}; use anyhow::Result; use std::io::IsTerminal; -mod diff; +pub(crate) mod diff; +pub mod edit; pub mod init; mod sync; diff --git a/src/main.rs b/src/main.rs index 01db66c..9152623 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,17 @@ mod sources; fn main() -> Result<()> { let args = Args::parse(); - if let Some(Command::Init) = args.command { - return job::init::InitJob::new().run(); + match args.command { + Some(Command::Init) => { + return job::init::InitJob::new().run(); + } + Some(Command::Edit(edit_args)) => { + let cfg = Config::from_file(&args.config) + .with_context(|| format!("failed to load config from '{}'", args.config))?; + let uri = edit_args.resolve_uri(&cfg)?; + return job::edit::EditJob::new(&uri)?.run(); + } + None => {} } let cfg = Config::from_file(&args.config) From ea87fc49694afd388c7b5ee50d0f23cb2426118f Mon Sep 17 00:00:00 2001 From: Ryan Marganti Date: Mon, 16 Mar 2026 12:46:40 -0400 Subject: [PATCH 3/3] error cleanup --- AGENTS.md | 20 ++++++++++++++++++++ src/config.rs | 6 +++++- src/job/edit.rs | 17 ++++++++--------- 3 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bbfac57 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# SecretSync + +A CLI utility for syncing secrets between Vault, local .env files, and Kubernetes secrets. +Provides a unified interface for managing secrets across multiple sources with support for +diffs and preset configurations. + +## Repo organization + +Root +├─ /src/config.rs: the core domain design +├─ /src/secrets.rs: the core domain design +├─ /src/job/: Job execution and orchestration +└─ /src/sources/: Secret source implementations (Vault, File, Kubernetes) + +## Verifying (MUST BE RUN BEFORE CONSIDERING A TASK COMPLETE) + +- `cargo fmt --all -- --check` +- `cargo test --all-features` +- `cargo clippy --all-targets --all-features -- -D warnings` +- `cargo build --all-features` diff --git a/src/config.rs b/src/config.rs index 4996722..b2170d6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -27,6 +27,9 @@ pub enum ConfigError { #[error("preset '{0}' not found in config file")] PresetNotFound(String), + #[error("--target is only valid when the source is a preset name, not a raw URI")] + TargetRequiresPreset, + #[error("must provide either a preset or both --from and --to arguments")] MissingArguments, @@ -86,7 +89,8 @@ impl EditArgs { EditTarget::To => preset.to.clone(), }) } else if self.target.is_some() { - Err(ConfigError::PresetNotFound(self.source.clone())) + // `--target` only makes sense when `source` is a preset name, not a raw URI + Err(ConfigError::TargetRequiresPreset) } else { Ok(self.source.clone()) } diff --git a/src/job/edit.rs b/src/job/edit.rs index 748ab3a..5522f3a 100644 --- a/src/job/edit.rs +++ b/src/job/edit.rs @@ -10,12 +10,12 @@ pub enum EditError { #[error("unable to create temporary file")] CreateTempFile(#[source] std::io::Error), + #[error("unable to write temporary file")] + WriteTempFile(#[source] std::io::Error), + #[error("unable to read temporary file")] ReadTempFile(#[source] std::io::Error), - #[error("could not determine an editor; set $VISUAL or $EDITOR")] - NoEditor, - #[error("editor exited with non-zero status")] EditorFailed, @@ -53,7 +53,7 @@ impl super::Job for EditJob { .tempfile() .map_err(EditError::CreateTempFile)?; - std::fs::write(temp_file.path(), &original_content).map_err(EditError::ReadTempFile)?; + std::fs::write(temp_file.path(), &original_content).map_err(EditError::WriteTempFile)?; let edited = loop { open_editor(temp_file.path())?; @@ -125,17 +125,16 @@ fn serialize_secrets(secrets: &Secrets) -> Result> { Ok(buf) } -/// Resolve the user's preferred editor. -fn resolve_editor() -> Result { +/// Resolve the user's preferred editor, falling back to `vi`. +fn resolve_editor() -> String { std::env::var("VISUAL") .or_else(|_| std::env::var("EDITOR")) - .or_else(|_| Ok("vi".to_string())) - .map_err(|_: std::env::VarError| EditError::NoEditor) + .unwrap_or_else(|_| "vi".to_string()) } /// Open the given file in the user's editor and wait for it to exit. fn open_editor(path: &std::path::Path) -> Result<()> { - let editor = resolve_editor()?; + let editor = resolve_editor(); let status = std::process::Command::new(&editor) .arg(path) .stdin(std::process::Stdio::inherit())