diff options
Diffstat (limited to '')
-rw-r--r-- | pkgs/by-name/ts/tskm/src/task/mod.rs | 322 |
1 files changed, 322 insertions, 0 deletions
diff --git a/pkgs/by-name/ts/tskm/src/task/mod.rs b/pkgs/by-name/ts/tskm/src/task/mod.rs new file mode 100644 index 00000000..c3a6d614 --- /dev/null +++ b/pkgs/by-name/ts/tskm/src/task/mod.rs @@ -0,0 +1,322 @@ +use std::{ + fmt::Display, + fs::{self, read_to_string, File}, + path::PathBuf, + process::Command, + str::FromStr, + sync::OnceLock, +}; + +use anyhow::{bail, Context, Result}; +use log::{debug, info, trace}; + +use crate::interface::project::ProjectName; + +/// The `taskwarrior` id of a task. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Ord, Eq)] +pub struct Id { + id: u64, +} +impl Id { + /// # Errors + /// When `task` execution fails + pub fn get_current() -> Result<Option<Self>> { + // We have ensured that only one task may be active + let self_str = run_task(&["+ACTIVE", "_ids"])?; + + if self_str.is_empty() { + Ok(None) + } else { + Self::from_str(&self_str).map(Some) + } + } + + /// # Errors + /// When `task` execution fails + pub fn to_uuid(&self) -> Result<String> { + let uuid = run_task(&[self.to_string().as_str(), "uuids"])?; + + Ok(uuid) + } + + /// # Panics + /// When internal assertions fail. + /// # Errors + /// When `task` execution fails + pub fn annotate(&self, message: &str) -> Result<()> { + run_task(&["annotate", self.to_string().as_str(), "--", message])?; + Ok(()) + } + + /// # Panics + /// When internal assertions fail. + /// # Errors + /// When `task` execution fails + pub fn start(&self) -> Result<()> { + info!("Activating {self}"); + + let output = run_task(&["start", self.to_string().as_str()])?; + assert!(output.is_empty()); + Ok(()) + } + /// # Panics + /// When internal assertions fail. + /// # Errors + /// When `task` execution fails + pub fn stop(&self) -> Result<()> { + info!("Stopping {self}"); + + let output = run_task(&["stop", self.to_string().as_str()])?; + assert!(output.is_empty()); + Ok(()) + } + + /// # Panics + /// When internal assertions fail. + /// # Errors + /// When `task` execution fails + pub fn description(&self) -> Result<String> { + let output = run_task(&["rc.context=none", "_zshids", self.to_string().as_str()])?; + let (id, desc) = output + .split_once(':') + .expect("The output should always contain one colon"); + assert_eq!(id.parse::<Id>().expect("This should be a valid id"), *self); + Ok(desc.to_owned()) + } + + /// # Panics + /// When internal assertions fail. + /// # Errors + /// When `task` execution fails + pub fn project(&self) -> Result<Project> { + let output = run_task(&[ + "rc.context=none", + "_get", + format!("{self}.project").as_str(), + ])?; + let project = Project::from_project_string(output.as_str()) + .expect("This comes from tw, it should be valid"); + Ok(project) + } +} + +impl Display for Id { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.id.fmt(f) + } +} + +impl FromStr for Id { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let id = u64::from_str(s)?; + Ok(Self { id }) + } +} + +/// A registered task Project +#[derive(Debug, Clone, PartialEq)] +pub struct Project { + /// The project name. + /// For example: + /// ```no_run + /// &["trinitrix", "testing", "infra"] + /// ``` + name: Vec<String>, +} + +static ALL_CACHE: OnceLock<Vec<Project>> = OnceLock::new(); +impl Project { + #[must_use] + pub fn to_project_display(&self) -> String { + self.name.join(".") + } + #[must_use] + pub fn to_context_display(&self) -> String { + self.name.join("_") + } + + /// # Errors + /// - When the string does not encode a previously registered project. + /// - When the string does not adhere to the project syntax. + pub fn from_project_string(s: &str) -> Result<Self> { + Self::from_input(s, ProjectName::from_project) + } + + /// # Errors + /// - When the string does not encode a previously registered project. + /// - When the string does not adhere to the context syntax. + pub fn from_context_string(s: &str) -> Result<Self> { + Self::from_input(s, ProjectName::from_context) + } + + fn from_input<F>(s: &str, f: F) -> Result<Self> + where + F: Fn(&str) -> ProjectName, + { + if s.is_empty() { + bail!("Your project is empty") + } + + let all = Self::all()?; + let me = Self::from_project_name_unchecked(&f(s)); + if all.contains(&me) { + Ok(me) + } else { + bail!( + "Your project '{}' is not registered!", + me.to_project_display() + ); + } + } + fn from_project_name_unchecked(pn: &ProjectName) -> Self { + Self { + name: pn.segments().to_owned(), + } + } + + /// Return all known valid projects. + /// + /// # Errors + /// When file operations fail. + /// + /// # Panics + /// Only when internal assertions fail. + pub fn all<'a>() -> Result<&'a [Project]> { + // Inlined from `OnceLock::get_or_try_init` + { + let this = &ALL_CACHE; + let f = || { + let file = dirs::config_local_dir() + .expect("Should be some") + .join("tskm/projects.list"); + let contents = read_to_string(&file) + .with_context(|| format!("Failed to read file: '{}'", file.display()))?; + + Ok::<_, anyhow::Error>( + contents + .lines() + .map(|s| Self::from_project_name_unchecked(&ProjectName::from_project(s))) + .collect::<Vec<_>>(), + ) + }; + + // Fast path check + // NOTE: We need to perform an acquire on the state in this method + // in order to correctly synchronize `LazyLock::force`. This is + // currently done by calling `self.get()`, which in turn calls + // `self.is_initialized()`, which in turn performs the acquire. + if let Some(value) = this.get() { + return Ok(value); + } + + this.set(f()?).expect( + "This should always be able to take our value, as we initialize only once.", + ); + + Ok(this.get().expect("This was initialized")) + } + } + + fn touch_dir(&self) -> PathBuf { + let lock_dir = dirs::data_dir() + .expect("Should be found") + .join("tskm/review"); + lock_dir.join(format!("{}.opened", self.to_project_display())) + } + + /// Mark this project as having been interacted with. + /// + /// # Errors + /// When IO operations fail. + pub fn touch(&self) -> Result<()> { + let lock_file = self.touch_dir(); + + File::create(&lock_file) + .with_context(|| format!("Failed to create lock_file at: {}", lock_file.display()))?; + + Ok(()) + } + /// Returns [`true`] if it was previously [`Self::touch`]ed. + #[must_use] + pub fn is_touched(&self) -> bool { + let lock_file = self.touch_dir(); + lock_file.exists() + } + /// Mark this project as having not been interacted with. + /// + /// # Errors + /// When IO operations fail. + pub fn untouch(&self) -> Result<()> { + let lock_file = self.touch_dir(); + + fs::remove_file(&lock_file) + .with_context(|| format!("Failed to create lock_file at: {}", lock_file.display()))?; + + Ok(()) + } + + /// # Errors + /// When `task` execution fails. + pub fn get_tasks(&self) -> Result<Vec<Id>> { + let output = run_task(&[ + "rc.context=none", + format!("project:{}", self.to_project_display()).as_str(), + "_ids", + ])?; + + if output.is_empty() { + Ok(vec![]) + } else { + output + .lines() + .map(Id::from_str) + .collect::<Result<Vec<Id>>>() + } + } + + /// # Errors + /// When `task` execution fails. + pub fn activate(&self) -> Result<()> { + debug!("Setting project {}", self.to_context_display()); + + run_task(&["context", self.to_context_display().as_str()]).map(|_| ()) + } + /// # Errors + /// When `task` execution fails. + pub fn clear() -> Result<()> { + debug!("Clearing active project"); + + run_task(&["context", "none"]).map(|_| ()) + } + + /// # Errors + /// When `task` execution fails. + pub fn get_current() -> Result<Option<Self>> { + let self_str = run_task(&["_get", "rc.context"])?; + + if self_str.is_empty() { + Ok(None) + } else { + Self::from_context_string(&self_str).map(Some) + } + } +} + +pub(crate) fn run_task(args: &[&str]) -> Result<String> { + debug!("Running task command: `task {}`", args.join(" ")); + + let output = Command::new("task") + .args(args) + .output() + .with_context(|| format!("Failed to run `task {}`", args.join(" ")))?; + + let stdout = String::from_utf8(output.stdout).context("Failed to read task output as utf8")?; + let stderr = String::from_utf8(output.stderr).context("Failed to read task output as utf8")?; + + trace!("Output (stdout): '{}'", stdout.trim()); + trace!("Output (stderr): '{}'", stderr.trim()); + + Ok(stdout.trim().to_owned()) +} |