// nixos-config - My current NixOS configuration // // Copyright (C) 2025 Benedikt Peetz // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of my nixos-config. // // You should have received a copy of the License along with this program. // If not, see . use std::{ collections::HashSet, fmt::Display, fs, io::Write, path::PathBuf, process::Command, str::FromStr, }; use anyhow::{bail, Context, Result}; use url::Url; use walkdir::WalkDir; pub mod handle; pub use handle::handle; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Tag(String); impl Tag { pub fn new(input: &str) -> Result { Self::from_str(input) } } impl FromStr for Tag { type Err = anyhow::Error; fn from_str(s: &str) -> std::result::Result { if let Some(tag) = s.strip_prefix('+') { if tag.contains(' ') { bail!("Your tag '{s}' should not whitespace.") } Ok(Self(tag.to_owned())) } else { bail!("Your tag '{s}' does not start with the required '+'"); } } } impl Display for Tag { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "+{}", self.0) } } impl Tag { #[must_use] pub fn as_str(&self) -> &str { &self.0 } } #[derive(Debug, Clone)] pub struct Input { url: Url, tags: HashSet, } impl FromStr for Input { type Err = anyhow::Error; fn from_str(s: &str) -> std::result::Result { if s.contains(' ') { let (url, tags) = s.split_once(' ').expect("Should work"); Ok(Self { url: Url::from_str(url)?, tags: { tags.trim() .split(' ') .map(Tag::new) .collect::>()? }, }) } else { Ok(Self { url: Url::from_str(s)?, tags: HashSet::new(), }) } } } impl Display for Input { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { if self.tags.is_empty() { self.url.fmt(f) } else { write!( f, "{} {}", self.url, self.tags .iter() .map(ToString::to_string) .collect::>() .join(" ") ) } } } impl Input { fn base_path() -> PathBuf { dirs::data_local_dir() .expect("This should be set") .join("tskm/inputs") } fn url_path(url: &Url) -> Result { let base_path = Self::base_path(); let url_path = base_path .join(url.scheme()) .join(url.host_str().unwrap_or("")) .join(url.path().trim_matches('/')); fs::create_dir_all(&url_path) .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?; Ok(url_path.join("url_value")) } #[must_use] pub fn url(&self) -> &Url { &self.url } /// Commit this constructed [`Input`] to storage. /// /// # Errors /// If IO operations fail. pub fn commit(&self) -> Result<()> { let url_path = Self::url_path(&self.url)?; let mut file = fs::OpenOptions::new() .create(true) .append(true) .open(&url_path) .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?; writeln!(file, "{self}")?; Self::git_commit(&format!("Add new url: '{self}'"))?; Ok(()) } /// Remove this constructed [`Input`] to storage. /// /// Beware that this does not take tags into account. /// /// # Errors /// If IO operations fail. pub fn remove(&self) -> Result<()> { let url_path = Self::url_path(&self.url)?; fs::remove_file(&url_path) .with_context(|| format!("Failed to remove file: '{}'", url_path.display()))?; let mut url_path = url_path.as_path(); while let Some(parent) = url_path.parent() { if fs::read_dir(parent)?.count() == 0 { fs::remove_dir(parent)?; } url_path = parent; } Self::git_commit(&format!("Remove url: '{self}'"))?; Ok(()) } /// Get all previously [`Self::commit`]ed inputs. /// /// # Errors /// When IO handling fails. /// /// # Panics /// If internal assertions fail. pub fn all() -> Result> { let mut output = vec![]; for entry in WalkDir::new(Self::base_path()) .min_depth(1) .into_iter() .filter_entry(|e| { let s = e.file_name().to_str(); s != Some(".git") }) { let entry = entry?; if !entry.file_type().is_file() { continue; } let url_value_file = entry.path(); assert!(url_value_file.ends_with("url_value")); let url_values = fs::read_to_string(PathBuf::from(url_value_file))?; output.extend( url_values .lines() .map(Self::from_str) .collect::, _>>()? .into_iter(), ); } Ok(output) } /// Commit your changes fn git_commit(message: &str) -> Result<()> { if !Self::base_path().join(".git").exists() { let status = Command::new("git") .args(["init"]) .current_dir(Self::base_path()) .status()?; if !status.success() { bail!("Git init failed!"); } } let status = Command::new("git") .args(["add", "."]) .current_dir(Self::base_path()) .status()?; if !status.success() { bail!("Git add . failed!"); } let status = Command::new("git") .args(["commit", "--message", message, "--no-gpg-sign"]) .current_dir(Self::base_path()) .status()?; if !status.success() { bail!("Git commit failed!"); } Ok(()) } }