Opinionated Android 15+ Linux Terminal Setup
android linux command-line-tools

Merge pull request #4 from tsirysndr/feat/diffs

feat: detect diffs in oh-my-droid.toml

authored by tsiry-sandratraina.com and committed by

GitHub 1f6c6e1e 67951733

+578 -37
+30 -3
src/cmd/setup.rs
··· 1 + use std::path::Path; 2 + 1 3 use anyhow::Error; 2 4 use owo_colors::OwoColorize; 3 5 4 - use crate::{config::Configuration, consts::CONFIG_FILE}; 6 + use crate::{config::Configuration, consts::CONFIG_FILE, diff::compare_configurations}; 5 7 6 8 pub fn setup(dry_run: bool, no_confirm: bool) -> Result<(), Error> { 7 9 let mut cfg = Configuration::default(); ··· 11 13 cfg = toml::from_str(&toml_str)?; 12 14 } 13 15 16 + let mut diffs = Vec::new(); 17 + 14 18 if !no_confirm && !dry_run { 19 + let home_dir = 20 + dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Failed to get home directory"))?; 21 + diffs = match Path::new(&home_dir).join(".oh-my-droid/lock.toml").exists() { 22 + true => { 23 + let old_cfg = Configuration::load_lock_file()?; 24 + compare_configurations(&old_cfg, &cfg) 25 + } 26 + false => compare_configurations(&Configuration::empty(), &cfg), 27 + }; 28 + 29 + if diffs.is_empty() { 30 + println!( 31 + "{}", 32 + "No changes detected. Your environment is already up to date.".green() 33 + ); 34 + return Ok(()); 35 + } 36 + 37 + println!("The following changes will be made:"); 38 + for d in diffs.iter().clone() { 39 + println!("{}", d); 40 + } 41 + 15 42 match std::path::Path::new(CONFIG_FILE).exists() { 16 43 true => { 17 44 println!( ··· 21 48 } 22 49 false => { 23 50 println!( 24 - "This wil set up your environment with the default configuration.\nDo you want to continue? (y/N)", 51 + "This will set up your environment with the default configuration.\nDo you want to continue? (y/N)", 25 52 ); 26 53 } 27 54 } ··· 34 61 } 35 62 } 36 63 37 - cfg.setup_environment(dry_run)?; 64 + cfg.setup_environment(dry_run, diffs)?; 38 65 39 66 Ok(()) 40 67 }
+180 -34
src/config.rs
··· 1 1 use anyhow::{Context, Error, Result}; 2 2 use owo_colors::OwoColorize; 3 3 use serde::{Deserialize, Serialize}; 4 - use std::{collections::HashMap, process::Command}; 4 + use std::{ 5 + collections::HashMap, 6 + fs::{self, File}, 7 + io::Write, 8 + process::Command, 9 + }; 5 10 6 - use crate::apply::SetupStep; 11 + use crate::{apply::SetupStep, diff::Diff}; 7 12 8 13 #[derive(Debug, Clone, Serialize, Deserialize)] 9 14 pub struct OhMyPosh { ··· 65 70 } 66 71 67 72 impl Configuration { 68 - pub fn setup_environment(&self, dry_run: bool) -> Result<()> { 73 + pub fn empty() -> Self { 74 + Self { 75 + stow: None, 76 + mise: None, 77 + nix: None, 78 + apt_get: None, 79 + pkgx: None, 80 + curl: None, 81 + blesh: None, 82 + oh_my_posh: None, 83 + zoxide: None, 84 + alias: None, 85 + tailscale: None, 86 + ssh: None, 87 + neofetch: None, 88 + } 89 + } 90 + 91 + pub fn setup_environment(&self, dry_run: bool, diffs: Vec<Diff>) -> Result<()> { 69 92 let output = Command::new("df") 70 93 .args(&["-BG", "--output=size", "/"]) 71 94 .output() ··· 86 109 return Err(Error::msg("Insufficient disk size: >= 7GB required")); 87 110 } 88 111 89 - let steps: Vec<SetupStep> = vec![ 90 - Some(SetupStep::Paths), 91 - self.apt_get.as_deref().map(SetupStep::AptGet), 92 - self.curl.as_ref().map(SetupStep::Curl), 93 - self.pkgx.as_ref().map(SetupStep::Pkgx), 94 - self.mise.as_ref().map(SetupStep::Mise), 95 - self.blesh.map(SetupStep::BleSh), 96 - self.zoxide.map(SetupStep::Zoxide), 97 - self.nix.as_ref().map(SetupStep::Nix), 98 - self.stow.as_ref().map(SetupStep::Stow), 99 - self.oh_my_posh 100 - .as_ref() 101 - .map(|omp| SetupStep::OhMyPosh(omp.theme.as_deref().unwrap_or("tokyonight_storm"))), 102 - self.alias.as_ref().map(SetupStep::Alias), 103 - self.ssh.as_ref().map(SetupStep::Ssh), 104 - self.tailscale.map(SetupStep::Tailscale), 105 - self.neofetch.map(SetupStep::Neofetch), 106 - ] 107 - .into_iter() 108 - .flatten() 109 - .collect(); 112 + let steps = self.diffs_to_setup_steps(diffs); 110 113 111 114 if dry_run { 112 115 println!("{}", "=== Dry Run: Environment Setup ===".yellow().bold()); ··· 115 118 println!("\n=> Step {}:\n{}", i + 1, step.format_dry_run()); 116 119 } 117 120 println!("{}", "=== Dry Run Complete ===".yellow().bold()); 118 - } else { 119 - for step in steps { 120 - step.run()?; 121 - } 122 - println!("{}", "Environment setup completed successfully 🎉".green()); 123 - println!("You can now open a new terminal to see the changes."); 124 - println!( 125 - "Or run {} to apply the changes to the current terminal session.", 126 - "source ~/.bashrc".green() 127 - ); 121 + return Ok(()); 122 + } 123 + 124 + for step in steps { 125 + step.run()?; 128 126 } 127 + 128 + self.write_lock_file()?; 129 + 130 + println!("{}", "Environment setup completed successfully 🎉".green()); 131 + println!("You can now open a new terminal to see the changes."); 132 + println!( 133 + "Or run {} to apply the changes to the current terminal session.", 134 + "source ~/.bashrc".green() 135 + ); 136 + 129 137 Ok(()) 138 + } 139 + 140 + pub fn write_lock_file(&self) -> Result<()> { 141 + let home_dir = dirs::home_dir().context("Failed to get home directory")?; 142 + let config_path = home_dir.join(".oh-my-droid/lock.toml"); 143 + 144 + fs::create_dir_all( 145 + config_path 146 + .parent() 147 + .context("Failed to get parent directory of lock file")?, 148 + )?; 149 + 150 + let mut file = File::create(&config_path).context("Failed to create lock file")?; 151 + file.write_all( 152 + toml::to_string(&self) 153 + .context("Failed to serialize config")? 154 + .as_bytes(), 155 + ) 156 + .context("Failed to write lock file")?; 157 + 158 + Ok(()) 159 + } 160 + 161 + pub fn load_lock_file() -> Result<Configuration> { 162 + let home_dir = dirs::home_dir().context("Failed to get home directory")?; 163 + let config_path = home_dir.join(".oh-my-droid/lock.toml"); 164 + 165 + let toml_str = fs::read_to_string(&config_path).context("Failed to read lock file")?; 166 + let loaded_config: Configuration = 167 + toml::from_str(&toml_str).context("Failed to parse lock file")?; 168 + 169 + Ok(loaded_config) 170 + } 171 + 172 + pub fn diffs_to_setup_steps<'a>(&'a self, diffs: Vec<Diff>) -> Vec<SetupStep<'a>> { 173 + let mut steps = Vec::new(); 174 + 175 + steps.push(SetupStep::Paths); 176 + 177 + for diff in diffs { 178 + match diff { 179 + Diff::Added(parent, _child, _value) => { 180 + self.add_setup_step_for_parent(&mut steps, &parent); 181 + } 182 + Diff::Changed(parent, _child, _old, _new) => { 183 + self.add_setup_step_for_parent(&mut steps, &parent); 184 + } 185 + Diff::Nested(parent, nested_diffs) => { 186 + self.add_setup_step_for_parent(&mut steps, &parent); 187 + let nested_steps = self.diffs_to_setup_steps(nested_diffs); 188 + steps.extend( 189 + nested_steps 190 + .into_iter() 191 + .filter(|step| !matches!(step, SetupStep::Paths)), 192 + ); 193 + } 194 + Diff::Removed(_parent, _child, _value) => { 195 + // For removed items, we typically don't need to do anything 196 + // as the setup is additive, but you could add cleanup logic here if needed 197 + } 198 + } 199 + } 200 + 201 + steps.dedup_by(|a, b| std::mem::discriminant(a) == std::mem::discriminant(b)); 202 + 203 + steps 204 + } 205 + 206 + fn add_setup_step_for_parent<'a>(&'a self, steps: &mut Vec<SetupStep<'a>>, parent: &str) { 207 + match parent { 208 + "apt-get" => { 209 + if let Some(apt_packages) = &self.apt_get { 210 + steps.push(SetupStep::AptGet(apt_packages)); 211 + } 212 + } 213 + "pkgx" => { 214 + if let Some(pkgx_packages) = &self.pkgx { 215 + steps.push(SetupStep::Pkgx(pkgx_packages)); 216 + } 217 + } 218 + "curl" => { 219 + if let Some(curl_installers) = &self.curl { 220 + steps.push(SetupStep::Curl(curl_installers)); 221 + } 222 + } 223 + "mise" => { 224 + if let Some(mise_tools) = &self.mise { 225 + steps.push(SetupStep::Mise(mise_tools)); 226 + } 227 + } 228 + "ble.sh" => { 229 + if let Some(blesh_enabled) = self.blesh { 230 + steps.push(SetupStep::BleSh(blesh_enabled)); 231 + } 232 + } 233 + "nix" => { 234 + if let Some(nix_packages) = &self.nix { 235 + steps.push(SetupStep::Nix(nix_packages)); 236 + } 237 + } 238 + "stow" => { 239 + if let Some(stow_configs) = &self.stow { 240 + steps.push(SetupStep::Stow(stow_configs)); 241 + } 242 + } 243 + "oh_my_posh" => { 244 + if let Some(oh_my_posh) = &self.oh_my_posh { 245 + let theme = oh_my_posh.theme.as_deref().unwrap_or("tokyonight_storm"); 246 + steps.push(SetupStep::OhMyPosh(theme)); 247 + } 248 + } 249 + "zoxide" => { 250 + if let Some(zoxide_enabled) = self.zoxide { 251 + steps.push(SetupStep::Zoxide(zoxide_enabled)); 252 + } 253 + } 254 + "alias" => { 255 + if let Some(aliases) = &self.alias { 256 + steps.push(SetupStep::Alias(aliases)); 257 + } 258 + } 259 + "ssh" => { 260 + if let Some(ssh_config) = &self.ssh { 261 + steps.push(SetupStep::Ssh(ssh_config)); 262 + } 263 + } 264 + "tailscale" => { 265 + if let Some(tailscale_enabled) = self.tailscale { 266 + steps.push(SetupStep::Tailscale(tailscale_enabled)); 267 + } 268 + } 269 + "neofetch" => { 270 + if let Some(neofetch_enabled) = self.neofetch { 271 + steps.push(SetupStep::Neofetch(neofetch_enabled)); 272 + } 273 + } 274 + _ => {} // Ignore unknown configuration keys 275 + } 130 276 } 131 277 } 132 278
+367
src/diff.rs
··· 1 + use owo_colors::OwoColorize; 2 + use std::{collections::HashMap, fmt}; 3 + 4 + use crate::config::{Configuration, OhMyPosh, SshConfig}; 5 + 6 + #[derive(Debug)] 7 + pub enum Diff { 8 + Added(String, String, String), // Parent, child, value 9 + Removed(String, String, String), // Parent, child, value 10 + Changed(String, String, String, String), // Parent, child, old value, new value 11 + Nested(String, Vec<Diff>), // Parent field, nested differences 12 + } 13 + 14 + impl fmt::Display for Diff { 15 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 16 + match self { 17 + Diff::Added(parent, child, value) => { 18 + if child.is_empty() { 19 + write!(f, "+ {}: {}", parent.green(), value.green()) 20 + } else { 21 + write!( 22 + f, 23 + "+ {}:\n + {}: {}", 24 + parent.green(), 25 + child.green(), 26 + value.green() 27 + ) 28 + } 29 + } 30 + Diff::Removed(parent, child, value) => { 31 + if child.is_empty() { 32 + write!(f, "- {}: {}", parent.magenta(), value.magenta()) 33 + } else { 34 + write!( 35 + f, 36 + "- {}:\n - {}: {}", 37 + parent.magenta(), 38 + child.magenta(), 39 + value.magenta() 40 + ) 41 + } 42 + } 43 + Diff::Changed(parent, child, old_value, new_value) => { 44 + if child.is_empty() { 45 + write!( 46 + f, 47 + "- {}: {}\n+ {}: {}", 48 + parent.magenta(), 49 + old_value.magenta(), 50 + parent.green(), 51 + new_value.green() 52 + ) 53 + } else { 54 + write!( 55 + f, 56 + "- {}:\n - {}: {}\n+ {}:\n + {}: {}", 57 + parent.magenta(), 58 + child.magenta(), 59 + old_value.magenta(), 60 + parent.green(), 61 + child.green(), 62 + new_value.green() 63 + ) 64 + } 65 + } 66 + Diff::Nested(parent, diffs) => { 67 + write!(f, "{}:", parent)?; 68 + for diff in diffs { 69 + write!(f, "\n {}", diff)?; 70 + } 71 + Ok(()) 72 + } 73 + } 74 + } 75 + } 76 + 77 + fn compare_hashmap( 78 + parent: &str, 79 + old: &Option<HashMap<String, String>>, 80 + new: &Option<HashMap<String, String>>, 81 + ) -> Vec<Diff> { 82 + let mut diffs = Vec::new(); 83 + match (old, new) { 84 + (None, Some(new_map)) => { 85 + for (key, value) in new_map { 86 + diffs.push(Diff::Added(parent.to_string(), key.clone(), value.clone())); 87 + } 88 + } 89 + (Some(old_map), None) => { 90 + for (key, value) in old_map { 91 + diffs.push(Diff::Removed( 92 + parent.to_string(), 93 + key.clone(), 94 + value.clone(), 95 + )); 96 + } 97 + } 98 + (Some(old_map), Some(new_map)) => { 99 + for (key, old_value) in old_map { 100 + match new_map.get(key) { 101 + None => diffs.push(Diff::Removed( 102 + parent.to_string(), 103 + key.clone(), 104 + old_value.clone(), 105 + )), 106 + Some(new_value) if new_value != old_value => { 107 + diffs.push(Diff::Changed( 108 + parent.to_string(), 109 + key.clone(), 110 + old_value.clone(), 111 + new_value.clone(), 112 + )); 113 + } 114 + _ => {} 115 + } 116 + } 117 + for (key, new_value) in new_map { 118 + if !old_map.contains_key(key) { 119 + diffs.push(Diff::Added( 120 + parent.to_string(), 121 + key.clone(), 122 + new_value.clone(), 123 + )); 124 + } 125 + } 126 + } 127 + (None, None) => {} 128 + } 129 + diffs 130 + } 131 + 132 + fn compare_vec(parent: &str, old: &Option<Vec<String>>, new: &Option<Vec<String>>) -> Vec<Diff> { 133 + let mut diffs = Vec::new(); 134 + match (old, new) { 135 + (None, Some(new_vec)) => { 136 + for item in new_vec { 137 + diffs.push(Diff::Added( 138 + parent.to_string(), 139 + "".to_string(), 140 + item.clone(), 141 + )); 142 + } 143 + } 144 + (Some(old_vec), None) => { 145 + for item in old_vec { 146 + diffs.push(Diff::Removed( 147 + parent.to_string(), 148 + "".to_string(), 149 + item.clone(), 150 + )); 151 + } 152 + } 153 + (Some(old_vec), Some(new_vec)) => { 154 + let old_set: std::collections::HashSet<_> = old_vec.iter().collect(); 155 + let new_set: std::collections::HashSet<_> = new_vec.iter().collect(); 156 + for item in new_set.difference(&old_set) { 157 + diffs.push(Diff::Added( 158 + parent.to_string(), 159 + "".to_string(), 160 + item.to_string(), 161 + )); 162 + } 163 + for item in old_set.difference(&new_set) { 164 + diffs.push(Diff::Removed( 165 + parent.to_string(), 166 + "".to_string(), 167 + item.to_string(), 168 + )); 169 + } 170 + } 171 + (None, None) => {} 172 + } 173 + diffs 174 + } 175 + 176 + fn compare_bool(parent: &str, old: &Option<bool>, new: &Option<bool>) -> Vec<Diff> { 177 + match (old, new) { 178 + (None, Some(new_val)) => vec![Diff::Added( 179 + parent.to_string(), 180 + "".to_string(), 181 + new_val.to_string(), 182 + )], 183 + (Some(old_val), None) => vec![Diff::Removed( 184 + parent.to_string(), 185 + "".to_string(), 186 + old_val.to_string(), 187 + )], 188 + (Some(old_val), Some(new_val)) if old_val != new_val => { 189 + vec![Diff::Changed( 190 + parent.to_string(), 191 + "".to_string(), 192 + old_val.to_string(), 193 + new_val.to_string(), 194 + )] 195 + } 196 + _ => vec![], 197 + } 198 + } 199 + 200 + fn compare_oh_my_posh(old: &Option<OhMyPosh>, new: &Option<OhMyPosh>) -> Vec<Diff> { 201 + let mut diffs = Vec::new(); 202 + match (old, new) { 203 + (None, Some(new_omp)) => { 204 + if let Some(theme) = &new_omp.theme { 205 + diffs.push(Diff::Added( 206 + "oh_my_posh".to_string(), 207 + "theme".to_string(), 208 + theme.clone(), 209 + )); 210 + } 211 + } 212 + (Some(old_omp), None) => { 213 + if let Some(theme) = &old_omp.theme { 214 + diffs.push(Diff::Removed( 215 + "oh_my_posh".to_string(), 216 + "theme".to_string(), 217 + theme.clone(), 218 + )); 219 + } 220 + } 221 + (Some(old_omp), Some(new_omp)) => match (&old_omp.theme, &new_omp.theme) { 222 + (None, Some(new_theme)) => { 223 + diffs.push(Diff::Added( 224 + "oh_my_posh".to_string(), 225 + "theme".to_string(), 226 + new_theme.clone(), 227 + )); 228 + } 229 + (Some(old_theme), None) => { 230 + diffs.push(Diff::Removed( 231 + "oh_my_posh".to_string(), 232 + "theme".to_string(), 233 + old_theme.clone(), 234 + )); 235 + } 236 + (Some(old_theme), Some(new_theme)) if old_theme != new_theme => { 237 + diffs.push(Diff::Changed( 238 + "oh_my_posh".to_string(), 239 + "theme".to_string(), 240 + old_theme.clone(), 241 + new_theme.clone(), 242 + )); 243 + } 244 + _ => {} 245 + }, 246 + (None, None) => {} 247 + } 248 + if !diffs.is_empty() { 249 + vec![Diff::Nested("oh_my_posh".to_string(), diffs)] 250 + } else { 251 + vec![] 252 + } 253 + } 254 + 255 + fn compare_ssh_config(old: &Option<SshConfig>, new: &Option<SshConfig>) -> Vec<Diff> { 256 + let mut diffs = Vec::new(); 257 + match (old, new) { 258 + (None, Some(new_ssh)) => { 259 + if let Some(port) = new_ssh.port { 260 + diffs.push(Diff::Added( 261 + "ssh".to_string(), 262 + "port".to_string(), 263 + port.to_string(), 264 + )); 265 + } 266 + if let Some(keys) = &new_ssh.authorized_keys { 267 + for key in keys { 268 + diffs.push(Diff::Added( 269 + "ssh".to_string(), 270 + "authorized_keys".to_string(), 271 + key.clone(), 272 + )); 273 + } 274 + } 275 + } 276 + (Some(old_ssh), None) => { 277 + if let Some(port) = old_ssh.port { 278 + diffs.push(Diff::Removed( 279 + "ssh".to_string(), 280 + "port".to_string(), 281 + port.to_string(), 282 + )); 283 + } 284 + if let Some(keys) = &old_ssh.authorized_keys { 285 + for key in keys { 286 + diffs.push(Diff::Removed( 287 + "ssh".to_string(), 288 + "authorized_keys".to_string(), 289 + key.clone(), 290 + )); 291 + } 292 + } 293 + } 294 + (Some(old_ssh), Some(new_ssh)) => { 295 + match (old_ssh.port, new_ssh.port) { 296 + (None, Some(new_port)) => { 297 + diffs.push(Diff::Added( 298 + "ssh".to_string(), 299 + "port".to_string(), 300 + new_port.to_string(), 301 + )); 302 + } 303 + (Some(old_port), None) => { 304 + diffs.push(Diff::Removed( 305 + "ssh".to_string(), 306 + "port".to_string(), 307 + old_port.to_string(), 308 + )); 309 + } 310 + (Some(old_port), Some(new_port)) if old_port != new_port => { 311 + diffs.push(Diff::Changed( 312 + "ssh".to_string(), 313 + "port".to_string(), 314 + old_port.to_string(), 315 + new_port.to_string(), 316 + )); 317 + } 318 + _ => {} 319 + } 320 + let key_diffs = compare_vec("ssh", &old_ssh.authorized_keys, &new_ssh.authorized_keys); 321 + diffs.extend(key_diffs.into_iter().map(|diff| match diff { 322 + Diff::Added(_, _, value) => { 323 + Diff::Added("ssh".to_string(), "authorized_keys".to_string(), value) 324 + } 325 + Diff::Removed(_, _, value) => { 326 + Diff::Removed("ssh".to_string(), "authorized_keys".to_string(), value) 327 + } 328 + Diff::Changed(_, _, old_value, new_value) => Diff::Changed( 329 + "ssh".to_string(), 330 + "authorized_keys".to_string(), 331 + old_value, 332 + new_value, 333 + ), 334 + Diff::Nested(_, _) => diff, // Unreachable 335 + })); 336 + } 337 + (None, None) => {} 338 + } 339 + if !diffs.is_empty() { 340 + vec![Diff::Nested("ssh".to_string(), diffs)] 341 + } else { 342 + vec![] 343 + } 344 + } 345 + 346 + pub fn compare_configurations(old: &Configuration, new: &Configuration) -> Vec<Diff> { 347 + let mut diffs = Vec::new(); 348 + 349 + diffs.extend(compare_hashmap("stow", &old.stow, &new.stow)); 350 + diffs.extend(compare_hashmap("mise", &old.mise, &new.mise)); 351 + diffs.extend(compare_hashmap("nix", &old.nix, &new.nix)); 352 + diffs.extend(compare_hashmap("pkgx", &old.pkgx, &new.pkgx)); 353 + diffs.extend(compare_hashmap("curl", &old.curl, &new.curl)); 354 + diffs.extend(compare_hashmap("alias", &old.alias, &new.alias)); 355 + 356 + diffs.extend(compare_vec("apt-get", &old.apt_get, &new.apt_get)); 357 + 358 + diffs.extend(compare_bool("blesh", &old.blesh, &new.blesh)); 359 + diffs.extend(compare_bool("zoxide", &old.zoxide, &new.zoxide)); 360 + diffs.extend(compare_bool("tailscale", &old.tailscale, &new.tailscale)); 361 + diffs.extend(compare_bool("neofetch", &old.neofetch, &new.neofetch)); 362 + 363 + diffs.extend(compare_oh_my_posh(&old.oh_my_posh, &new.oh_my_posh)); 364 + diffs.extend(compare_ssh_config(&old.ssh, &new.ssh)); 365 + 366 + diffs 367 + }
+1
src/main.rs
··· 12 12 pub mod command; 13 13 pub mod config; 14 14 pub mod consts; 15 + pub mod diff; 15 16 16 17 fn cli() -> Command { 17 18 let banner = format!(