use std::{ collections::HashSet, fmt::Display, fs::{self, read_to_string, File}, 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 NoWhitespaceString(String); impl NoWhitespaceString { /// # Panics /// If the input contains whitespace. #[must_use] pub fn new(input: String) -> Self { if input.contains(' ') { panic!("Your input '{input}' contains whitespace. I did not expect that.") } else { Self(input) } } } impl Display for NoWhitespaceString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl NoWhitespaceString { #[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| { if let Some(tag) = tag.strip_prefix('+') { Ok(NoWhitespaceString::new(tag.to_owned())) } else { bail!("Your tag '{tag}' does not start with the required '+'"); } }) .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() .fold(String::new(), |mut acc, tag| { acc.push('+'); acc.push_str(tag.as_str()); acc.push(' '); acc }) .trim() ) } } } 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.to_string()); 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 url_content = { if url_path.exists() { read_to_string(&url_path)? } else { String::new() } }; let mut file = File::create(&url_path) .with_context(|| format!("Failed to open file: '{}'", url_path.display()))?; writeln!(file, "{url_content}{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(()) } /// Commit your changes fn git_commit(message: &str) -> Result<()> { 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(()) } /// 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() .to_str() .expect("All of these should be URLs and thus valid strings"); assert!(url_value_file.ends_with("/url_value")); let url = { let base = url_value_file .strip_prefix(&format!("{}/", Self::base_path().display())) .expect("This will exist"); let (proto, path) = base.split_once(':').expect("This will countain a :"); let path = path.strip_suffix("/url_value").expect("Will exist"); Url::from_str(&format!("{proto}:/{path}")) .expect("This was a URL, it should still be one") }; let tags = { let url_values = read_to_string(PathBuf::from(url_value_file))?; url_values .lines() .map(|line| { let input = Self::from_str(line)?; Ok::<_, anyhow::Error>(input.tags) }) .collect::>, _>>()? .into_iter() .flatten() .collect() }; output.push(Self { url, tags }); } Ok(output) } }