aboutsummaryrefslogtreecommitdiffstats
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.rs373
1 files changed, 373 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..1362615d
--- /dev/null
+++ b/pkgs/by-name/ts/tskm/src/task/mod.rs
@@ -0,0 +1,373 @@
+// nixos-config - My current NixOS configuration
+//
+// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de>
+// 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 <https://www.gnu.org/licenses/gpl-3.0.txt>.
+
+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 taskchampion::Tag;
+
+use crate::{interface::project::ProjectName, state::State};
+
+/// The `taskwarrior` id of a task.
+#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Ord, Eq)]
+pub struct Task {
+ uuid: taskchampion::Uuid,
+}
+
+impl From<&taskchampion::Task> for Task {
+ fn from(value: &taskchampion::Task) -> Self {
+ Self {
+ uuid: value.get_uuid(),
+ }
+ }
+}
+impl From<&taskchampion::TaskData> for Task {
+ fn from(value: &taskchampion::TaskData) -> Self {
+ Self {
+ uuid: value.get_uuid(),
+ }
+ }
+}
+
+impl Task {
+ pub async fn from_working_set(id: usize, state: &mut State) -> Result<Option<Self>> {
+ Ok(state
+ .replica()
+ .working_set()
+ .await?
+ .by_index(id)
+ .map(|uuid| Self { uuid }))
+ }
+
+ pub async fn get_current(state: &mut State) -> Result<Option<Self>> {
+ let tasks = state
+ .replica()
+ .pending_tasks()
+ .await?
+ .into_iter()
+ .filter(taskchampion::Task::is_active)
+ .collect::<Vec<_>>();
+
+ assert!(
+ tasks.len() <= 1,
+ "We have ensured that only one task may be active, via a hook"
+ );
+ if let Some(active) = tasks.first() {
+ Ok(Some(Self::from(active)))
+ } else {
+ Ok(None)
+ }
+ }
+
+ #[must_use]
+ pub fn uuid(&self) -> &taskchampion::Uuid {
+ &self.uuid
+ }
+ pub async fn working_set_id(&self, state: &mut State) -> Result<usize> {
+ Ok(state
+ .replica()
+ .working_set()
+ .await?
+ .by_uuid(self.uuid)
+ .expect("The task should be in the working set"))
+ }
+
+ async fn as_task(&self, state: &mut State) -> Result<taskchampion::Task> {
+ Ok(state
+ .replica()
+ .get_task(self.uuid)
+ .await?
+ .expect("We have the task from this replica, it should still be in it"))
+ }
+
+ /// Adds a tag to the task, to show the user that it has additional neorg data.
+ pub async fn mark_neorg_data(&self, state: &mut State) -> Result<()> {
+ let mut ops = vec![];
+ self.as_task(state)
+ .await?
+ .add_tag(&Tag::from_str("neorg_data").expect("Is valid"), &mut ops)?;
+ state.replica().commit_operations(ops).await?;
+ Ok(())
+ }
+
+ /// Try to start this task.
+ /// It will stop previously active tasks.
+ pub async fn start(&self, state: &mut State) -> Result<()> {
+ info!("Activating {self}");
+
+ if let Some(active) = Self::get_current(state).await? {
+ active.stop(state).await?;
+ }
+
+ let mut ops = vec![];
+ self.as_task(state).await?.start(&mut ops)?;
+ state.replica().commit_operations(ops).await?;
+ Ok(())
+ }
+
+ /// Stops this task.
+ pub async fn stop(&self, state: &mut State) -> Result<()> {
+ info!("Stopping {self}");
+
+ let mut ops = vec![];
+ self.as_task(state).await?.stop(&mut ops)?;
+ state.replica().commit_operations(ops).await?;
+ Ok(())
+ }
+
+ pub async fn description(&self, state: &mut State) -> Result<String> {
+ Ok(self.as_task(state).await?.get_description().to_owned())
+ }
+
+ pub async fn project(&self, state: &mut State) -> Result<Project> {
+ let output = {
+ let task = self.as_task(state).await?;
+ let task_data = task.into_task_data();
+ task_data
+ .get("project")
+ .expect("Every task should have a project")
+ .to_owned()
+ };
+ let project = Project::from_project_string(output.as_str().trim())
+ .expect("This comes from tw, it should be valid");
+ Ok(project)
+ }
+}
+
+impl Display for Task {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ self.uuid.fmt(f)
+ }
+}
+
+impl FromStr for Task {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let uuid = taskchampion::Uuid::from_str(s)?;
+ Ok(Self { uuid })
+ }
+}
+
+/// 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 async fn get_tasks(&self, state: &mut State) -> Result<Vec<Task>> {
+ Ok(state
+ .replica()
+ .pending_task_data()
+ .await?
+ .into_iter()
+ .filter(|t| t.get("project").expect("Is set") == self.to_project_display())
+ .map(|t| Task::from(&t))
+ .collect())
+ }
+
+ /// # 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());
+
+ if !output.status.success() {
+ bail!(
+ "Command `task {}` failed with status: {}",
+ args.join(" "),
+ output.status
+ );
+ }
+
+ Ok(stdout.trim().to_owned())
+}