about summary refs log tree commit diff stats
path: root/pkgs/by-name/ts/tskm/src/task
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--pkgs/by-name/ts/tskm/src/task/mod.rs322
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())
+}