doing-plugins 0.3.1

Export and import plugins for the doing CLI
Documentation
use doing_config::Config;
use doing_taskpaper::Entry;
use doing_template::renderer::RenderOptions;
use indexmap::IndexMap;
use serde::Serialize;

use crate::{ExportPlugin, Plugin, PluginSettings};

/// Date format matching Brett's doing: `2026-03-19 16:06:00 -0500`
const JSON_DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S %z";

/// Export plugin that renders entries as a JSON array grouped by section.
pub struct JsonExport;

impl ExportPlugin for JsonExport {
  fn render(&self, entries: &[Entry], _options: &RenderOptions, _config: &Config) -> String {
    let mut sections: IndexMap<String, Vec<JsonItem>> = IndexMap::new();
    for entry in entries {
      sections
        .entry(entry.section().to_string())
        .or_default()
        .push(JsonItem::from_entry(entry));
    }

    let output: Vec<JsonSection> = sections
      .into_iter()
      .map(|(section, items)| JsonSection {
        items,
        section,
      })
      .collect();

    serde_json::to_string_pretty(&output).unwrap_or_else(|_| "[]".into())
  }
}

impl Plugin for JsonExport {
  fn name(&self) -> &str {
    "json"
  }

  fn settings(&self) -> PluginSettings {
    PluginSettings {
      trigger: "json".into(),
    }
  }
}

/// A single entry serialized as JSON, matching Brett's doing format.
#[derive(Serialize)]
struct JsonItem {
  date: String,
  done: bool,
  end_date: Option<String>,
  id: String,
  note: String,
  section: String,
  tags: Vec<String>,
  timers: Vec<JsonTimer>,
  title: String,
}

impl JsonItem {
  fn from_entry(entry: &Entry) -> Self {
    let tags: Vec<String> = entry.tags().iter().map(|t| t.name().to_string()).collect();

    let end_date = entry.done_date().map(|d| d.format(JSON_DATE_FORMAT).to_string());

    let done = entry.finished();

    let timer_end = end_date.clone();
    let timers = vec![JsonTimer {
      end: timer_end,
      start: entry.date().format(JSON_DATE_FORMAT).to_string(),
    }];

    let note = if entry.note().is_empty() {
      String::new()
    } else {
      entry.note().to_line("\n")
    };

    Self {
      date: entry.date().format(JSON_DATE_FORMAT).to_string(),
      done,
      end_date,
      id: entry.id().to_string(),
      note,
      section: entry.section().to_string(),
      tags,
      timers,
      title: entry.full_title(),
    }
  }
}

/// A section containing its entries, matching Brett's doing format.
#[derive(Serialize)]
struct JsonSection {
  items: Vec<JsonItem>,
  section: String,
}

/// A timer with start and optional end timestamps.
#[derive(Serialize)]
struct JsonTimer {
  end: Option<String>,
  start: String,
}

#[cfg(test)]
mod test {
  use doing_taskpaper::{Note, Tag, Tags};

  use super::*;
  use crate::test_helpers::{sample_date, sample_options};

  fn expected_date(hour: u32, minute: u32) -> String {
    sample_date(17, hour, minute).format(JSON_DATE_FORMAT).to_string()
  }

  mod json_export_name {
    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn it_returns_json() {
      assert_eq!(JsonExport.name(), "json");
    }
  }

  mod json_export_render {
    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn it_renders_empty_entries() {
      let config = Config::default();
      let options = sample_options();

      let output = JsonExport.render(&[], &options, &config);
      let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();

      assert!(parsed.as_array().unwrap().is_empty());
    }

    #[test]
    fn it_renders_entry_with_tags() {
      let config = Config::default();
      let options = sample_options();
      let entry = Entry::new(
        sample_date(17, 14, 30),
        "Working on project",
        Tags::from_iter(vec![
          Tag::new("coding", None::<String>),
          Tag::new("done", Some("2024-03-17 15:00")),
        ]),
        Note::from_text("A note"),
        "Currently",
        None::<String>,
      );

      let output = JsonExport.render(&[entry], &options, &config);
      let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
      let sections = parsed.as_array().unwrap();

      assert_eq!(sections.len(), 1);
      assert_eq!(sections[0]["section"], "Currently");

      let items = sections[0]["items"].as_array().unwrap();
      assert_eq!(items.len(), 1);
      assert_eq!(items[0]["title"], "Working on project @coding @done(2024-03-17 15:00)");
      assert_eq!(items[0]["date"], expected_date(14, 30));
      assert_eq!(items[0]["section"], "Currently");
      assert_eq!(items[0]["note"], "A note");
      assert_eq!(items[0]["done"], true);
      assert_eq!(items[0]["end_date"], expected_date(15, 0));

      let tags = items[0]["tags"].as_array().unwrap();
      assert_eq!(tags.len(), 2);
      assert_eq!(tags[0], "coding");
      assert_eq!(tags[1], "done");

      let timers = items[0]["timers"].as_array().unwrap();
      assert_eq!(timers.len(), 1);
      assert_eq!(timers[0]["start"], expected_date(14, 30));
      assert_eq!(timers[0]["end"], expected_date(15, 0));

      assert!(items[0]["id"].as_str().unwrap().len() == 32);
    }

    #[test]
    fn it_renders_unfinished_entry() {
      let config = Config::default();
      let options = sample_options();
      let entry = Entry::new(
        sample_date(17, 14, 30),
        "In progress",
        Tags::new(),
        Note::new(),
        "Currently",
        None::<String>,
      );

      let output = JsonExport.render(&[entry], &options, &config);
      let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
      let items = &parsed[0]["items"];

      assert_eq!(items[0]["done"], false);
      assert!(items[0]["end_date"].is_null());
      assert!(items[0]["timers"][0]["end"].is_null());
    }

    #[test]
    fn it_groups_entries_by_section() {
      let config = Config::default();
      let options = sample_options();
      let entries = vec![
        Entry::new(
          sample_date(17, 14, 0),
          "Task A",
          Tags::new(),
          Note::new(),
          "Currently",
          None::<String>,
        ),
        Entry::new(
          sample_date(17, 13, 0),
          "Task B",
          Tags::from_iter(vec![Tag::new("done", Some("2024-03-17 14:00"))]),
          Note::new(),
          "Archive",
          None::<String>,
        ),
        Entry::new(
          sample_date(17, 15, 0),
          "Task C",
          Tags::new(),
          Note::new(),
          "Currently",
          None::<String>,
        ),
      ];

      let output = JsonExport.render(&entries, &options, &config);
      let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
      let sections = parsed.as_array().unwrap();

      assert_eq!(sections.len(), 2);
      // IndexMap preserves insertion order (document order), not alphabetical
      assert_eq!(sections[0]["section"], "Currently");
      assert_eq!(sections[0]["items"].as_array().unwrap().len(), 2);
      assert_eq!(sections[1]["section"], "Archive");
      assert_eq!(sections[1]["items"].as_array().unwrap().len(), 1);
    }

    #[test]
    fn it_preserves_section_order() {
      let config = Config::default();
      let options = sample_options();
      let entries = vec![
        Entry::new(
          sample_date(17, 14, 0),
          "Z task",
          Tags::new(),
          Note::new(),
          "Zebra",
          None::<String>,
        ),
        Entry::new(
          sample_date(17, 13, 0),
          "A task",
          Tags::new(),
          Note::new(),
          "Alpha",
          None::<String>,
        ),
        Entry::new(
          sample_date(17, 12, 0),
          "M task",
          Tags::new(),
          Note::new(),
          "Middle",
          None::<String>,
        ),
      ];

      let output = JsonExport.render(&entries, &options, &config);
      let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
      let sections = parsed.as_array().unwrap();

      // Sections appear in document (insertion) order, not alphabetical
      assert_eq!(sections[0]["section"], "Zebra");
      assert_eq!(sections[1]["section"], "Alpha");
      assert_eq!(sections[2]["section"], "Middle");
    }

    #[test]
    fn it_produces_valid_json() {
      let config = Config::default();
      let options = sample_options();
      let entry = Entry::new(
        sample_date(17, 14, 30),
        "Task with \"quotes\" and, commas",
        Tags::new(),
        Note::new(),
        "Currently",
        None::<String>,
      );

      let output = JsonExport.render(&[entry], &options, &config);

      assert!(serde_json::from_str::<serde_json::Value>(&output).is_ok());
    }
  }

  mod json_export_settings {
    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn it_returns_json_trigger() {
      let settings = JsonExport.settings();

      assert_eq!(settings.trigger, "json");
    }
  }
}