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 fn from_working_set(id: usize, state: &mut State) -> Result> { Ok(state .replica() .working_set()? .by_index(id) .map(|uuid| Self { uuid })) } pub fn get_current(state: &mut State) -> Result> { let tasks = state .replica() .pending_tasks()? .into_iter() .filter(taskchampion::Task::is_active) .collect::>(); 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 } fn as_task(&self, state: &mut State) -> Result { Ok(state .replica() .get_task(self.uuid)? .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 fn mark_neorg_data(&self, state: &mut State) -> Result<()> { let mut ops = vec![]; self.as_task(state)? .add_tag(&Tag::from_str("neorg_data").expect("Is valid"), &mut ops)?; state.replica().commit_operations(ops)?; Ok(()) } /// Try to start this task. /// It will stop previously active tasks. pub fn start(&self, state: &mut State) -> Result<()> { info!("Activating {self}"); if let Some(active) = Self::get_current(state)? { active.stop(state)?; } let mut ops = vec![]; self.as_task(state)?.start(&mut ops)?; state.replica().commit_operations(ops)?; Ok(()) } /// Stops this task. pub fn stop(&self, state: &mut State) -> Result<()> { info!("Stopping {self}"); let mut ops = vec![]; self.as_task(state)?.stop(&mut ops)?; state.replica().commit_operations(ops)?; Ok(()) } pub fn description(&self, state: &mut State) -> Result { Ok(self.as_task(state)?.get_description().to_owned()) } pub fn project(&self, state: &mut State) -> Result { let output = { let task = self.as_task(state)?; 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()) .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 { 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, } static ALL_CACHE: OnceLock> = 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::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::from_input(s, ProjectName::from_context) } fn from_input(s: &str, f: F) -> Result 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::>(), ) }; // 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, state: &mut State) -> Result> { Ok(state .replica() .pending_task_data()? .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> { 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 { 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()) }