···11+parse internal links from body
22+33+This is some !test bold text!.
44+here's some =highlighted text=
55+66+and finally some *italics!*
77+88+here's [a link](https://ngp.computer).
99+1010+and an internal link: [[tsk-11]]. This should add a backlink
1111+1212+and some _underlined text_
1313+1414+some ~strikethrough~.
+11
.tsk/archive/tsk-15.tsk
···11+Add link identification to tasks
22+33+[This crate](https://docs.rs/linkify/latest/linkify/) should be helpful for
44+that, though I've only done a cursory search.
55+66+The intent here is to provide a command that allows you to list and open a link
77+from a provided task and optionall use a system-handler to open the link.
88+99+Something along the lines of `tsk hyperlinks -t 12 -s`, which will scan TSK-12 for
1010+hyperlinks (or email addresses?) and pipe them to `fzf` for selection and
1111+opening (-s flag) or simply print the link if no option is specified.
+3
.tsk/archive/tsk-16.tsk
···11+Add ability to search archived tasks with find command
22+33+Probably want to use `-a` flag or something
+5
.tsk/archive/tsk-17.tsk
···11+Add reopen command
22+33+Reopen will allow selecting an *archived* task (note: this needs to be
44+restricted to archived tasks) and recreating the symlink in tasks/ to mark it as
55+open.
···11+add "raw" output option for show
22+33+Should probably be some variant of `tsk show -x` or something to skip the parsing step,
44+just display the body of the text directly.
55+66+This does suggest I should add a `format` subcommand that takes in a body and outputs
77+the parsed + styled form, could be useful for editor plugins
···11+fix issue where links use absolute paths
22+33+ MacOS
44+55+
+7
.tsk/archive/tsk-21.tsk
···11+Add command to setup git stuff
22+33+Will want to prompt to add `.tsk` to the `.git/info/exclude` file (or
44+.gitignore/globally) and *probably* set up
55+[metastore](https://github.com/przemoc/metastore)
66+77+What else should we do?
+3
.tsk/archive/tsk-22.tsk
···11+Figure out why link parsing isn't working in tsk-21
22+33+Actually, it appears *all* styling is broken somehow
+2
.tsk/archive/tsk-23.tsk
···11+Allow selecting which task to follow links from
22+
+2
.tsk/archive/tsk-24.tsk
···11+properly handle removing links from task
22+
···11+Add tool to clean up old tasks not in index
22+33+Previously the `drop` command did not remove the symlink in .tsk/tasks when a
44+task was dropped, only removing it from the index. I don't recall if this was
55+because my original design expected the index to be the source of truth on
66+whether a task was prioritized or not or if I simply forgot to add it. Either
77+way, drop now properly removes the symlink but basically every existing
88+workspace has a bunch of junk in it. I can write a simple script that deletes
99+all symlinks that aren't present in the index.
1010+1111+This does suggest that I should add a reindex command to add tasks that are not
1212+present in the index but are in the tasks folder to the index at the *bottom*,
1313+preserving the existing order of the index.
1414+1515+1616+How about just adding a `fixup` command that does this and reindex as laid out in
1717+[[tsk-18]].
···11+automatically add backlinks
22+33+I need to parse on save/edit/create for outgoing internal links. If any exist and their
44+corresponding task exists, update the targetted task with a backlink reference
55+66+Using [[tsk-11]] as my test.
+2
.tsk/archive/tsk-7.tsk
···11+allow for creating tasks that don't go to top of stack
22+
···11+fix timestamp storage and parsing
22+33+It looks like timestamps aren't being stored or parsed from the index anymore.
44+I'm not quite sure how this broke, but it's like an issue in `StackItem`'s
55+FromStr and Display implementations.
+12
.tsk/index
···11+tsk-30 Add flag to only print IDs in list command 1763257109
22+tsk-28 Add tool to clean up old tasks not in index 1735006519
33+tsk-10 foreign workspaces 1732594198
44+tsk-21 Add command to setup git stuff 1732594198
55+tsk-8 IMAP4-based sync 1767469318
66+tsk-17 Add reopen command 1732594198
77+tsk-16 Add ability to search archived tasks with find command 1767466011
88+tsk-15 Add link identification to tasks 1732594198
99+tsk-9 fix timestamp storage and parsing 1732594198
1010+tsk-7 allow for creating tasks that don't go to top of stack 1732594198
1111+tsk-13 user-defined labels 1732594198
1212+tsk-18 Add reindex command 1735006716
···11+MIT License
22+33+Copyright (c) 2024 Noah Pederson
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
···11+tsk
22+===
33+44+A fast & simple CLI task manager
55+--------------------------------
66+77+The motivation for tsk is simple: make managing tasks as fast and easy as
88+possible with a focus on small, ephemeral tasks.
99+1010+Secondary goals include:
1111+1212+- Provide the minimum amount of features necessary to be usable by a team
1313+- Support local and non-local workspaces
1414+- Be adaptable to almost any environment, even employer-mandated JIRA use
1515+- Be flexible, within reason
1616+1717+tsk takes inspiration from git and FORTH and is expected to be used alongside
1818+the former.
1919+2020+Dependencies
2121+------------
2222+2323+tsk is written in Rust. To compile from source, a recent Rust toolchain is
2424+required. As of now, it is developed using Rust 1.81.0.
2525+2626+Additionally, for fuzzy-finding functionality, the fzf command must be installed
2727+and in the shell's PATH.
2828+2929+ https://github.com/junegunn/fzf
3030+3131+tsk workspaces must be created on filesystems that support symlinking.
3232+3333+Task-level metadata requires Linux's xattr(7) API and a filesystem that supports
3434+it. Patches that implement this for other operating systems are welcome.
3535+3636+tsk expects to run on POSIX-like systems. Microsoft Windows and other
3737+non-UNIX-ey operating systems will never be directly supported.
3838+3939+4040+Installation
4141+------------
4242+4343+```sh
4444+cargo install --locked tsk-cli
4545+```
4646+4747+4848+Building
4949+--------
5050+5151+```sh
5252+cargo install --path .
5353+```
5454+5555+Make sure ~/.cargo/bin is in your PATH.
5656+5757+Overview
5858+--------
5959+6060+A summary of commands and their functionality can be seen with:
6161+6262+ tsk help
6363+6464+tsk uses plain text files for all of its functionality. A workspace is a folder
6565+that contains a .tsk/ directory created with the `tsk init` command. The
6666+presence of a .tsk/ folder is searched recursively upwards until a filesystem
6767+boundary or root is encountered. This means you can nest workspaces and use
6868+folders to namespace tasks while also using tsk commands at any location within
6969+a workspace.
7070+7171+New tasks are created with the `tsk push` command. A title is always required,
7272+but can be modified later. A unique identifier is selected automatically and a
7373+file with the title and any body contents supplied are stored in the
7474+.tsk/archive folder. A symlink is then created in the .tsk/tasks folder marking
7575+the task as "open." The task is then added to the top of the "stack" by having
7676+its tsk-ID and title added to the .tsk/index file.
7777+7878+The contents of the stack may be printed using the `tsk list` command.
7979+8080+Tasks are marked as "completed" and removed from the index with the `tsk drop`
8181+command. They will remain in the .tsk/archive folder, but are excluded from
8282+fuzzy searches by default.
8383+8484+The priority of a task may be manipulated in any of several ways:
8585+8686+`tsk swap` swaps the top two task on the stack
8787+8888+ โโโโโโโโโโโ โโโโโโโโโโโ
8989+ โ tsk-100 โ โ tsk-102 โ
9090+ โโโโโโโโโโโ โโโฌโโโโโโฒโโ
9191+ โ โ
9292+ โโโโโโโโโโโ โโโผโโโโโโดโโ
9393+ โ tsk-102 โ โโโโโโโโบ โ tsk-100 โ
9494+ โโโโโโโโโโโ โโโโโโโโโโโ
9595+9696+ โโโโโโโโโโโ โโโโโโโโโโโ
9797+ โ tsk-108 โ โ tsk-108 โ
9898+ โโโโโโโโโโโ โโโโโโโโโโโ
9999+100100+`tsk rot` moves the 3rd task on the stack to the top of the stack and shifts
101101+the first and second down
102102+103103+ โโโโโโโโโโโ โโโโโโโโโโโ
104104+ โ tsk-100 โ โ tsk-108 โโโ
105105+ โโโโโโโโโโโ โโโโโโฌโโโโโ โ
106106+ โ โ
107107+ โโโโโโโโโโโ โโโโโโผโโโโโ โ
108108+ โ tsk-102 โ โโโโโโโโบ โ tsk-100 โ โ
109109+ โโโโโโโโโโโ โโโโโโฌโโโโโ โ
110110+ โ โ
111111+ โโโโโโโโโโโ โโโโโโผโโโโโ โ
112112+ โ tsk-108 โ โ tsk-102 โโโ
113113+ โโโโโโโโโโโ โโโโโโโโโโโ
114114+115115+`tsk tor` moves the task on the top of the stack behind the third, shifting the
116116+second and third tasks up.
117117+118118+ โโโโโโโโโโโ โโโโโโโโโโโ
119119+ โ tsk-100 โ โ tsk-102 โโโ
120120+ โโโโโโโโโโโ โโโโโโฒโโโโโ โ
121121+ โ โ
122122+ โโโโโโโโโโโ โโโโโโดโโโโโ โ
123123+ โ tsk-102 โ โโโโโโโโบ โ tsk-108 โ โ
124124+ โโโโโโโโโโโ โโโโโโฒโโโโโ โ
125125+ โ โ
126126+ โโโโโโโโโโโ โโโโโโดโโโโโ โ
127127+ โ tsk-108 โ โ tsk-100 โโโ
128128+ โโโโโโโโโโโ โโโโโโโโโโโ
129129+130130+`tsk prioritize` will take a selected task and move it to the top of the stack
131131+from any other position in the stack. It is selected either by ID or using fuzzy
132132+finding.
133133+134134+`tsk deprioritize` moves a selected task to the bottom of the stack from any
135135+position.
136136+137137+Roadmap
138138+-------
139139+140140+- Configurable workspace-scoped prefix tags (tsk- vs example-)
141141+- Extended Attribute-based Metadata
142142+- Task Linking
143143+- IMAP4/SMTP-based synchronization and sharing
144144+- Export + sync with external task managers
145145+ - GitLab
146146+ - GitHub
147147+ - JIRA
148148+ - Obsidian
149149+ - More?
150150+- tsk -> html export
151151+- Editor plugins
152152+ - nvim-telescope
153153+ - nvim
154154+ - others?
155155+- Man page
156156+157157+Format
158158+------
159159+160160+The tsk text format can be thought of as a derivative of Markdown and scdoc, but
161161+not quite either. Markdown is a great baseline for rich-text while scdoc
162162+restricts itself to rich text formatting that can be displayed effectively in a
163163+terminal. As tsk's primary goal is to be a fast, terminal-centric task manager,
164164+this property is a *must.*
165165+166166+Additionally, it should be similar enough to Markdown such that it is easy to
167167+export to other applications, as outlined above in the roadmap.
168168+169169+Meanwhile, both Markdown and scdoc have some limitations and make choices that,
170170+while appropriate for their domain, are not appropriate for tsk. Some notable
171171+differences from both:
172172+173173+- There is only one way to do any type of formatting
174174+- Hard line breaks are real, not imaginary
175175+- Inline formatting control characters must be surrounded by space, newline, or
176176+ common punctuation
177177+178178+A core feature of the format is *linking*. That is, references to other tasks
179179+utilizing wiki-link style links: `[[]]`. The content within the link is mapped
180180+to the local workspace if the `tsk-` prefix is used, or a mapped non-local
181181+workspace if another prefix is used. These mappings are specified using a text
182182+file within the .tsk folder.
183183+184184+A quick overview of the format:
185185+186186+- \!Bolded\! text is surrounded by exclamation marks (!)
187187+- \*Italicized\* text is surrounded by single asterisks (*)
188188+- \_Underlined\_ text is surrounded by underscores (_)
189189+- \~Strikethrough\~ text is surrounded by tildes (~)
190190+- \=Highlighted\= text is surrounded by equals signs (=)
191191+- \`Inline code\` is surrounded by backticks (`)
192192+193193+Links like in Markdown, along with the wiki-style links documented above.
194194+Raw links can also be written as \<https://example.com\>.
195195+196196+Misc
197197+----
198198+199199+tsk is heavily inspired by git. It mimics its folder structure and some
200200+commands. The concept of the stack is inspired by FORTH and the observation that
201201+most of the time, only the top 3 priorities at any given moment matter and tasks
202202+tend to be created when they are most important. This facilitates small,
203203+frequent creation of tasks that help both document problems and manage
204204+fast-paced work environments.
205205+206206+tsk is not intended to be checked into git, however there is not a reason that it
207207+cannot be. This repository's development is managed using tsk itself.
208208+209209+Git does *not* track extended filesystem attributes. If you wish to avoid constantly
210210+re-indexing, use something like metastore:
211211+212212+ https://github.com/przemoc/metastore
+61
src/attrs.rs
···11+use std::collections::BTreeMap;
22+use std::collections::btree_map::Entry;
33+use std::collections::btree_map::{IntoIter as BTreeIntoIter, Iter as BTreeMapIter};
44+use std::iter::Chain;
55+66+type Map = BTreeMap<String, String>;
77+88+#[allow(dead_code)]
99+/// Holds xattributes in a way that allows for differentiating between attributes that have been
1010+/// added/modified or that were present when reading the file. This is an *optimization* over
1111+/// infrequently modified values.
1212+#[derive(Default, Clone, Debug)]
1313+pub(crate) struct Attrs {
1414+ pub written: Map,
1515+ pub updated: Map,
1616+}
1717+1818+impl IntoIterator for Attrs {
1919+ type Item = (String, String);
2020+2121+ type IntoIter = Chain<BTreeIntoIter<String, String>, BTreeIntoIter<String, String>>;
2222+2323+ fn into_iter(self) -> Self::IntoIter {
2424+ self.written.into_iter().chain(self.updated)
2525+ }
2626+}
2727+2828+#[allow(dead_code)]
2929+impl Attrs {
3030+ pub(crate) fn from_written(written: Map) -> Self {
3131+ Self {
3232+ written,
3333+ ..Default::default()
3434+ }
3535+ }
3636+3737+ pub(crate) fn get(&self, key: &str) -> Option<&String> {
3838+ self.updated.get(key).or_else(|| self.written.get(key))
3939+ }
4040+4141+ pub(crate) fn insert(&mut self, key: String, value: String) -> Option<String> {
4242+ match self.updated.entry(key.clone()) {
4343+ Entry::Occupied(mut e) => Some(e.insert(value)),
4444+ Entry::Vacant(e) => {
4545+ e.insert(value);
4646+ let maybe_old_value = self.written.get(&key);
4747+ maybe_old_value.cloned()
4848+ }
4949+ }
5050+ }
5151+5252+ pub(crate) fn is_empty(&self) -> bool {
5353+ self.updated.is_empty() && self.written.is_empty()
5454+ }
5555+5656+ pub(crate) fn iter(
5757+ &self,
5858+ ) -> Chain<BTreeMapIter<'_, String, String>, BTreeMapIter<'_, String, String>> {
5959+ self.written.iter().chain(self.updated.iter())
6060+ }
6161+}
···11use crate::errors::{Error, Result};
22+use std::ffi::OsStr;
23use std::fmt::Display;
34use std::io::Write;
45use std::process::{Command, Stdio};
···6778/// Sends each item as a line to stdin to the `fzf` command and returns the selected item's string
89/// representation as output
99-pub fn select<I>(input: impl IntoIterator<Item = I>) -> Result<Option<I>>
1010+pub fn select<I, O, S>(
1111+ input: impl IntoIterator<Item = I>,
1212+ extra: impl IntoIterator<Item = S>,
1313+) -> Result<Option<O>>
1014where
1111- I: Display + FromStr,
1212- Error: From<<I as FromStr>::Err>,
1515+ O: FromStr,
1616+ I: Display,
1717+ Error: From<<O as FromStr>::Err>,
1818+ S: AsRef<OsStr>,
1319{
1414- let mut child = Command::new("fzf")
1515- .args(["-d", "\t"])
2020+ let mut command = Command::new("fzf");
2121+ let mut child = command
2222+ .args(extra)
2323+ .arg("--read0")
1624 .stderr(Stdio::inherit())
1725 .stdin(Stdio::piped())
1826 .stdout(Stdio::piped())
···2028 // unwrap: this can never fail
2129 let child_in = child.stdin.as_mut().unwrap();
2230 for item in input.into_iter() {
2323- write!(child_in, "{}\n", item.to_string())?;
3131+ write!(child_in, "{item}\0")?;
2432 }
2533 let output = child.wait_with_output()?;
2634 if output.stdout.is_empty() {
+264-66
src/main.rs
···11+mod attrs;
12mod errors;
23mod fzf;
34mod stack;
55+mod task;
46mod util;
57mod workspace;
66-use clap_complete::{generate, Shell};
88+use clap_complete::{Shell, generate};
79use errors::Result;
88-use std::io;
1010+use std::io::{self, Write};
911use std::path::PathBuf;
1012use std::process::exit;
1313+use std::str::FromStr as _;
1114use std::{env::current_dir, io::Read};
1212-use workspace::{Id, TaskIdentifier, Workspace};
1515+use task::ParsedLink;
1616+use workspace::{Id, Task, TaskIdentifier, Workspace};
13171418//use smol;
1519//use iocraft::prelude::*;
1616-use clap::{value_parser, Args, CommandFactory, Parser, Subcommand};
2020+use clap::{Args, CommandFactory, Parser, Subcommand};
1721use edit::edit as open_editor;
18221923fn default_dir() -> PathBuf {
2024 current_dir().unwrap()
2125}
22262727+fn parse_id(s: &str) -> std::result::Result<Id, &'static str> {
2828+ Id::from_str(s).map_err(|_| "Unable to parse tsk- ID")
2929+}
3030+2331#[derive(Parser)]
2432// TODO: add long_about
2533#[command(version, about)]
2634struct Cli {
3535+ /// Override the tsk root directory.
2736 #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")]
2837 dir: Option<PathBuf>,
2938 // TODO: other global options
···5261 #[command(flatten)]
5362 title: Title,
5463 },
6464+ /// Creates a new task just like `push`, but instead of putting it at the top of the stack, it
6565+ /// puts it at the bottom
6666+ Append {
6767+ /// Whether to open $EDITOR to edit the content of the task. The first line if the
6868+ /// resulting file will be the task's title. The body follows the title after two newlines,
6969+ /// similr to the format of a commit message.
7070+ #[arg(short = 'e', default_value_t = false)]
7171+ edit: bool,
7272+7373+ /// The body of the task. It may be specified as either a string using quotes or the
7474+ /// special character '-' to read from stdin.
7575+ #[arg(short = 'b')]
7676+ body: Option<String>,
7777+7878+ /// The title of the task as a raw string. It mus be proceeded by two dashes (--).
7979+ #[command(flatten)]
8080+ title: Title,
8181+ },
8282+ /// Print the task stack. This will include just TSK-IDs and the title.
5583 List {
5684 /// Whether to list all tasks in the task stack. If specified, -c / count is ignored.
5785 #[arg(short = 'a', default_value_t = false)]
···7810679107 /// Use fuzzy finding with `fzf` to search for a task
80108 Find {
8181- /// Include the contents of tasks in the search criteria.
8282- #[arg(short = 'b', default_value_t = false)]
8383- search_body: bool,
8484- /// Include archived tasks in the search criteria. Combine with `-b` to include archived
8585- /// bodies in the search criteria.
8686- #[arg(short = 'a', default_value_t = false)]
8787- search_archived: bool,
109109+ #[command(flatten)]
110110+ args: FindArgs,
111111+ /// Whether to print the a shortened tsk ID (just the integer portion). Defaults to *false*
112112+ #[arg(short = 'f', default_value_t = false)]
113113+ short_id: bool,
114114+ },
115115+116116+ /// Prints the contents of a task, parsing the body as rich text and formatting it using ANSI
117117+ /// escape sequences.
118118+ Show {
119119+ /// Shows raw file attributes for the file
120120+ #[arg(short = 'x', default_value_t = false)]
121121+ show_attrs: bool,
122122+123123+ #[arg(short = 'R', default_value_t = false)]
124124+ raw: bool,
125125+ /// The [TSK-]ID of the task to display
126126+ #[command(flatten)]
127127+ task_id: TaskId,
128128+ },
881298989- #[arg(short = 't', default_value_t = true)]
9090- full_id: bool,
130130+ /// Follow a link that is parsed from a task body. It may be an internal or external link (ie.
131131+ /// a url or a wiki-style link using double square brackets). When using the `tsk show`
132132+ /// command, links that are successfully parsed get a numeric superscript that may be used to
133133+ /// address the link. That number should be supplied to the -l/link_index where it will be
134134+ /// subsequently followed opened or shown.
135135+ Follow {
136136+ /// The task whose body will be searched for links.
137137+ #[command(flatten)]
138138+ task_id: TaskId,
139139+ /// The index of the link to open. Must be supplied.
140140+ #[arg(short = 'l', default_value_t = 1)]
141141+ link_index: usize,
142142+ /// When opening an internal link, whether to show or edit the addressed task.
143143+ #[arg(short = 'e', default_value_t = false)]
144144+ edit: bool,
91145 },
9214693147 /// Drops the task on the top of the stack and archives it.
9494- Drop,
148148+ Drop {
149149+ /// The [TSK-]ID of the task to drop.
150150+ #[command(flatten)]
151151+ task_id: TaskId,
152152+ },
95153154154+ /// Moves the 3rd item on the stack to the front of the stack, shifting everything else down by
155155+ /// one. If there are less than 3 tasks on the stack, has no effect.
96156 Rot,
157157+ /// Moves the task on the top of the stack back behind the 2nd element, shifting the next two
158158+ /// task up.
97159 Tor,
981609999- Reprioritize {
161161+ /// Prioritizes an arbitrary task to the top of the stack.
162162+ Prioritize {
100163 /// The [TSK-]ID to prioritize. If it exists, it is moved to the top of the stack.
101164 #[command(flatten)]
102165 task_id: TaskId,
103166 },
167167+168168+ /// Deprioritizes a task to the bottom of the stack.
169169+ Deprioritize {
170170+ /// The [TSK-]ID to deprioritize. If it exists, it is moved to the bottom of the stack.
171171+ #[command(flatten)]
172172+ task_id: TaskId,
173173+ },
104174}
105175106176#[derive(Args)]
···123193 id: Option<u32>,
124194125195 /// The ID of the task to select with the 'tsk-' prefix.
126126- #[arg(short = 'T', value_name = "TSK-ID", value_parser = value_parser!(String))]
196196+ #[arg(short = 'T', value_name = "TSK-ID", value_parser = parse_id)]
127197 tsk_id: Option<Id>,
128198129199 /// Selects a task relative to the top of the stack.
···131201 #[arg(short = 'r', value_name = "RELATIVE", default_value_t = 0)]
132202 relative_id: u32,
133203134134- /// Use fuzzy finding to search for and select a task.
135135- /// Does not support searching task bodies or archived tasks.
204204+ #[command(flatten)]
205205+ find: Find,
206206+}
207207+208208+/// Use fuzzy finding to search for and select a task.
209209+/// Does not support searching task bodies or archived tasks.
210210+#[derive(Args)]
211211+#[group(required = false, multiple = true)]
212212+struct Find {
213213+ /// Use fuzzy finding to select a task.
136214 #[arg(short = 'f', value_name = "FIND", default_value_t = false)]
137215 find: bool,
216216+ #[command(flatten)]
217217+ args: FindArgs,
218218+}
219219+220220+#[derive(Args)]
221221+#[group(required = false, multiple = false)]
222222+struct FindArgs {
223223+ /// Exclude the contents of tasks in the search criteria.
224224+ #[arg(short = 'b', default_value_t = false)]
225225+ exclude_body: bool,
226226+ /* TODO: implement this
227227+ /// Include archived tasks in the search criteria. Combine with `-b` to include archived
228228+ /// bodies in the search criteria.
229229+ #[arg(short = 'a', default_value_t = false)]
230230+ search_archived: bool,
231231+ */
138232}
139233140234impl From<TaskId> for TaskIdentifier {
141235 fn from(value: TaskId) -> Self {
142236 if let Some(id) = value.id.map(Id::from).or(value.tsk_id) {
143237 TaskIdentifier::Id(id)
238238+ } else if value.find.find {
239239+ TaskIdentifier::Find {
240240+ exclude_body: value.find.args.exclude_body,
241241+ archived: false,
242242+ }
144243 } else {
145145- if value.find {
146146- TaskIdentifier::Find
147147- } else {
148148- TaskIdentifier::Relative(value.relative_id)
149149- }
244244+ TaskIdentifier::Relative(value.relative_id)
150245 }
151246 }
152247}
···154249fn main() {
155250 let cli = Cli::parse();
156251 let dir = cli.dir.unwrap_or(default_dir());
157157- let result = match cli.command {
252252+ let var_name = match cli.command {
158253 Commands::Init => command_init(dir),
159254 Commands::Push { edit, body, title } => command_push(dir, edit, body, title),
255255+ Commands::Append { edit, body, title } => command_append(dir, edit, body, title),
160256 Commands::List { all, count } => command_list(dir, all, count),
161257 Commands::Swap => command_swap(dir),
258258+ Commands::Show {
259259+ task_id,
260260+ raw,
261261+ show_attrs,
262262+ } => command_show(dir, task_id, show_attrs, raw),
263263+ Commands::Follow {
264264+ task_id,
265265+ link_index,
266266+ edit,
267267+ } => command_follow(dir, task_id, link_index, edit),
162268 Commands::Edit { task_id } => command_edit(dir, task_id),
163269 Commands::Completion { shell } => command_completion(shell),
164164- Commands::Drop => command_drop(dir),
165165- Commands::Find {
166166- full_id,
167167- search_body,
168168- search_archived,
169169- } => command_find(dir, full_id, search_body, search_archived),
270270+ Commands::Drop { task_id } => command_drop(dir, task_id),
271271+ Commands::Find { args, short_id } => command_find(dir, short_id, args),
170272 Commands::Rot => Workspace::from_path(dir).unwrap().rot(),
171273 Commands::Tor => Workspace::from_path(dir).unwrap().tor(),
172172- Commands::Reprioritize { task_id } => command_reprioritize(dir, task_id),
274274+ Commands::Prioritize { task_id } => command_prioritize(dir, task_id),
275275+ Commands::Deprioritize { task_id } => command_deprioritize(dir, task_id),
173276 };
277277+ let result = var_name;
174278 match result {
175279 Ok(_) => exit(0),
176280 Err(e) => {
···180284 }
181285}
182286287287+fn taskid_from_tsk_id(tsk_id: Id) -> TaskId {
288288+ TaskId {
289289+ tsk_id: Some(tsk_id),
290290+ id: None,
291291+ relative_id: 0,
292292+ find: Find {
293293+ find: false,
294294+ args: FindArgs { exclude_body: true },
295295+ },
296296+ }
297297+}
298298+183299fn command_init(dir: PathBuf) -> Result<()> {
184300 Workspace::init(dir)
185301}
186302187187-fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
188188- let workspace = Workspace::from_path(dir)?;
303303+fn create_task(
304304+ workspace: &mut Workspace,
305305+ edit: bool,
306306+ body: Option<String>,
307307+ title: Title,
308308+) -> Result<Task> {
189309 let mut title = if let Some(title) = title.title {
190310 title
191311 } else if let Some(title) = title.title_simple {
192192- let joined = title.join(" ");
193193- joined
312312+ title.join(" ")
194313 } else {
195314 "".to_string()
196315 };
197197- let mut body = body.unwrap_or_default();
316316+ // If no body was explicitly provided and the title contains newlines,
317317+ // treat the first line as the title and the rest as the body (like git commit -m)
318318+ let mut body = if body.is_none() {
319319+ if let Some((first_line, rest)) = title.split_once('\n') {
320320+ let extracted_body = rest.to_string();
321321+ title = first_line.to_string();
322322+ extracted_body
323323+ } else {
324324+ String::new()
325325+ }
326326+ } else {
327327+ // Body was explicitly provided, so strip any newlines from title
328328+ title = title.replace(['\n', '\r'], " ");
329329+ body.unwrap_or_default()
330330+ };
198331 if body == "-" {
199332 // add newline so you can type directly in the shell
200200- eprintln!("");
333333+ //eprintln!("");
201334 body.clear();
202335 std::io::stdin().read_to_string(&mut body)?;
203336 }
···208341 body = content.1.to_string();
209342 }
210343 }
344344+ // Ensure title never contains newlines (invariant for index file format)
345345+ title = title.replace(['\n', '\r'], " ");
211346 let task = workspace.new_task(title, body)?;
347347+ workspace.handle_metadata(&task, None)?;
348348+ Ok(task)
349349+}
350350+351351+fn command_push(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
352352+ let mut workspace = Workspace::from_path(dir)?;
353353+ let task = create_task(&mut workspace, edit, body, title)?;
212354 workspace.push_task(task)
213355}
214356357357+fn command_append(dir: PathBuf, edit: bool, body: Option<String>, title: Title) -> Result<()> {
358358+ let mut workspace = Workspace::from_path(dir)?;
359359+ let task = create_task(&mut workspace, edit, body, title)?;
360360+ workspace.append_task(task)
361361+}
362362+215363fn command_list(dir: PathBuf, all: bool, count: usize) -> Result<()> {
216364 let workspace = Workspace::from_path(dir)?;
217217- let stack = if all {
218218- workspace.read_stack()?
219219- } else {
220220- workspace.read_stack()?
221221- };
365365+ let stack = workspace.read_stack()?;
366366+222367 if stack.empty() {
223368 println!("*No tasks*");
224369 exit(0);
225225- } else {
226226- if !all {
227227- for stack_item in stack.into_iter().take(count) {
228228- println!("{stack_item}");
229229- }
370370+ }
371371+372372+ for (_, stack_item) in stack
373373+ .into_iter()
374374+ .enumerate()
375375+ .take_while(|(idx, _)| all || idx < &count)
376376+ {
377377+ if let Some(parsed) = task::parse(&stack_item.title) {
378378+ println!("{}\t{}", stack_item.id, parsed.content.trim());
230379 } else {
231231- for stack_item in stack.into_iter() {
232232- println!("{stack_item}");
233233- }
380380+ println!("{stack_item}");
234381 }
235382 }
236383 Ok(())
···246393 let workspace = Workspace::from_path(dir)?;
247394 let id: TaskIdentifier = id.into();
248395 let mut task = workspace.task(id)?;
396396+ let pre_links = task::parse(&task.to_string()).map(|pt| pt.intenal_links());
249397 let new_content = open_editor(format!("{}\n\n{}", task.title.trim(), task.body.trim()))?;
250398 if let Some((title, body)) = new_content.split_once("\n") {
251251- task.title = title.to_string();
399399+ // Ensure title never contains newlines (invariant for index file format)
400400+ task.title = title.replace(['\n', '\r'], " ");
252401 task.body = body.to_string();
402402+ workspace.handle_metadata(&task, pre_links)?;
253403 task.save()?;
254404 }
255405 Ok(())
···260410 Ok(())
261411}
262412263263-fn command_drop(dir: PathBuf) -> Result<()> {
264264- if let Some(id) = Workspace::from_path(dir)?.drop()? {
413413+fn command_drop(dir: PathBuf, task_id: TaskId) -> Result<()> {
414414+ if let Some(id) = Workspace::from_path(dir)?.drop(task_id.into())? {
265415 eprint!("Dropped ");
266416 println!("{id}");
267417 } else {
···271421 Ok(())
272422}
273423274274-fn command_find(
275275- dir: PathBuf,
276276- full_id: bool,
277277- search_body: bool,
278278- search_archived: bool,
279279-) -> Result<()> {
280280- let id = Workspace::from_path(dir)?.search(None, search_body, search_archived)?;
424424+fn command_find(dir: PathBuf, short_id: bool, find_args: FindArgs) -> Result<()> {
425425+ let id = Workspace::from_path(dir)?.search(None, !find_args.exclude_body, false)?;
281426 if let Some(id) = id {
282282- if full_id {
283283- println!("{id}");
284284- } else {
427427+ if short_id {
285428 // print as integer
286429 println!("{}", id.0);
430430+ } else {
431431+ println!("{id}");
287432 }
288433 } else {
289289- eprintln!("No task to drop.");
434434+ eprintln!("No task selected.");
290435 exit(1);
291436 }
292437 Ok(())
293438}
294439295295-fn command_reprioritize(dir: PathBuf, task_id: TaskId) -> Result<()> {
296296- Workspace::from_path(dir)?.reprioritize(task_id.into())
440440+fn command_prioritize(dir: PathBuf, task_id: TaskId) -> Result<()> {
441441+ Workspace::from_path(dir)?.prioritize(task_id.into())
442442+}
443443+444444+fn command_deprioritize(dir: PathBuf, task_id: TaskId) -> Result<()> {
445445+ Workspace::from_path(dir)?.deprioritize(task_id.into())
446446+}
447447+448448+fn command_show(dir: PathBuf, task_id: TaskId, show_attrs: bool, raw: bool) -> Result<()> {
449449+ let task = Workspace::from_path(dir)?.task(task_id.into())?;
450450+ // YAML front-matter style. YAML is gross, but it's what everyone uses!
451451+ if show_attrs && !task.attributes.is_empty() {
452452+ println!("---");
453453+ for (attr, value) in task.attributes.iter() {
454454+ println!("{attr}: \"{value}\"");
455455+ }
456456+ println!("---");
457457+ }
458458+ match task::parse(&task.to_string()) {
459459+ Some(styled_task) if !raw => {
460460+ writeln!(io::stdout(), "{}", styled_task.content)?;
461461+ }
462462+ _ => {
463463+ println!("{task}");
464464+ }
465465+ }
466466+ Ok(())
467467+}
468468+469469+fn command_follow(dir: PathBuf, task_id: TaskId, link_index: usize, edit: bool) -> Result<()> {
470470+ let task = Workspace::from_path(dir.clone())?.task(task_id.into())?;
471471+ if let Some(parsed_task) = task::parse(&task.to_string()) {
472472+ if link_index == 0 || link_index > parsed_task.links.len() {
473473+ eprintln!("Link index out of bounds.");
474474+ exit(1);
475475+ }
476476+ let link = &parsed_task.links[link_index - 1];
477477+ match link {
478478+ ParsedLink::External(url) => {
479479+ open::that_detached(url.as_str())?;
480480+ Ok(())
481481+ }
482482+ ParsedLink::Internal(id) => {
483483+ let taskid = taskid_from_tsk_id(*id);
484484+ if edit {
485485+ command_edit(dir, taskid)
486486+ } else {
487487+ command_show(dir, taskid, false, false)
488488+ }
489489+ }
490490+ }
491491+ } else {
492492+ eprintln!("Unable to parse any links from body.");
493493+ exit(1);
494494+ }
297495}
+24-17
src/stack.rs
···4455use crate::errors::{Error, Result};
66use crate::util;
77-use std::collections::vec_deque::Iter;
87use std::collections::VecDeque;
88+use std::collections::vec_deque::Iter;
99use std::fmt::Display;
1010+use std::fs::File;
1011use std::io::{self, BufRead, BufReader, Seek, Write};
1212+use std::path::Path;
1113use std::str::FromStr;
1214use std::time::{Duration, SystemTime, UNIX_EPOCH};
1313-use std::{fs::File, path::PathBuf};
14151516use nix::fcntl::{Flock, FlockArg};
1617···6768 let mut parts = s.trim().split("\t");
6869 let id: Id = parts
6970 .next()
7070- .ok_or(Error::Parse(format!(
7171- "Incomplete index line. Missing tsk ID"
7272- )))?
7171+ .ok_or(Error::Parse(
7272+ "Incomplete index line. Missing tsk ID".to_owned(),
7373+ ))?
7374 .parse()?;
7475 let title: String = parts
7576 .next()
7676- .ok_or(Error::Parse(format!(
7777- "Incomplete index line. Missing title."
7878- )))?
7777+ .ok_or(Error::Parse(
7878+ "Incomplete index line. Missing title.".to_owned(),
7979+ ))?
7980 .trim()
8081 .to_string();
8182 // parse the timestamp as an integer
···96979798impl StackItem {
9899 /// Parses a [`StackItem`] from a string. The expected format is a tab-delimited line with the
9999- /// files: task id title
100100- fn from_line(workspace_path: &PathBuf, line: String) -> Result<Self> {
100100+ /// files: task id title
101101+ fn from_line(workspace_path: &Path, line: String) -> Result<Self> {
101102 let mut stack_item: StackItem = line.parse()?;
102103103104 let task = util::flopen(
104105 workspace_path
105106 .join(TASKSFOLDER)
106106- .join(stack_item.id.to_filename()),
107107+ .join(stack_item.id.filename()),
107108 FlockArg::LockExclusive,
108109 )?;
109110 let task_modify_time = task.metadata()?.modified()?;
···125126}
126127127128impl TaskStack {
128128- pub fn from_tskdir(workspace_path: &PathBuf) -> Result<Self> {
129129+ pub fn from_tskdir(workspace_path: &Path) -> Result<Self> {
129130 let file = util::flopen(workspace_path.join(INDEXFILE), FlockArg::LockExclusive)?;
130131 let index = BufReader::new(&*file).lines();
131132 let mut all = VecDeque::new();
···142143 self.file.seek(std::io::SeekFrom::Start(0))?;
143144 self.file.set_len(0)?;
144145 for item in self.all.iter() {
145145- self.file.write_all(format!("{item}\n").as_bytes())?;
146146+ let time = item.modify_time.duration_since(UNIX_EPOCH)?.as_secs();
147147+ self.file
148148+ .write_all(format!("{item}\t{}\n", time).as_bytes())?;
146149 }
147150 Ok(())
148151 }
···151154 self.all.push_front(item);
152155 }
153156157157+ pub fn push_back(&mut self, item: StackItem) {
158158+ self.all.push_back(item);
159159+ }
160160+154161 pub fn pop(&mut self) -> Option<StackItem> {
155162 self.all.pop_front()
156163 }
···158165 pub fn swap(&mut self) {
159166 let tip = self.all.pop_front();
160167 let second = self.all.pop_front();
161161- if tip.is_some() && second.is_some() {
162162- self.all.push_front(tip.unwrap());
163163- self.all.push_front(second.unwrap());
168168+ if let Some((tip, second)) = tip.zip(second) {
169169+ self.all.push_front(tip);
170170+ self.all.push_front(second);
164171 }
165172 }
166173···172179 self.all.remove(index)
173180 }
174181175175- pub fn iter(&self) -> Iter<StackItem> {
182182+ pub fn iter(&self) -> Iter<'_, StackItem> {
176183 self.all.iter()
177184 }
178185
+413
src/task.rs
···11+#![allow(dead_code)]
22+33+use std::{collections::HashSet, str::FromStr};
44+use url::Url;
55+66+use crate::workspace::Id;
77+use colored::Colorize;
88+99+/// Returns true if the character is a word boundary (whitespace or punctuation)
1010+fn is_boundary(c: char) -> bool {
1111+ c.is_whitespace() || c.is_ascii_punctuation()
1212+}
1313+1414+#[derive(Debug, Eq, PartialEq, Clone, Copy)]
1515+enum ParserState {
1616+ // Started by ` =`, terminated by `=
1717+ Highlight(usize, usize),
1818+ // Started by ` [`, terminated by `](`
1919+ Linktext(usize, usize),
2020+ // Started by `](`, terminated by `) `, must immedately follow a Linktext
2121+ Link(usize, usize),
2222+ RawLink(usize, usize),
2323+ // Started by ` [[`, terminated by `]] `
2424+ InternalLink(usize, usize),
2525+ // Started by ` *`, terminated by `* `
2626+ Italics(usize, usize),
2727+ // Started by ` !`, termianted by `!`
2828+ Bold(usize, usize),
2929+ // Started by ` _`, terminated by `_ `
3030+ Underline(usize, usize),
3131+ // Started by ` -`, terminated by `- `
3232+ Strikethrough(usize, usize),
3333+3434+ // TODO: implement these.
3535+ // Started by `_ `, terminated by `_`
3636+ UnorderedList(usize, u8),
3737+ // Started by `^\w+1.`, terminated by `\n`
3838+ OrderedList(usize, u8),
3939+ // Started by `^`````, terminated by a [`ParserOpcode::BlockEnd`]
4040+ BlockStart(usize),
4141+ // Started by `$````, is terminal itself. It must appear on its own line and be preceeded by a
4242+ // `\n` and followed by a `\n`
4343+ BlockEnd(usize),
4444+ // Started by ` ``, terminated by `` ` or `\n`
4545+ InlineBlock(usize, usize),
4646+ // Started by `^\w+>`, terminated by `\n`
4747+ Blockquote(usize),
4848+}
4949+5050+#[derive(Debug, Eq, PartialEq, Clone)]
5151+pub(crate) enum ParsedLink {
5252+ Internal(Id),
5353+ External(Url),
5454+}
5555+5656+pub(crate) struct ParsedTask {
5757+ pub(crate) content: String,
5858+ pub(crate) links: Vec<ParsedLink>,
5959+}
6060+6161+impl ParsedTask {
6262+ pub(crate) fn intenal_links(&self) -> HashSet<Id> {
6363+ let mut out = HashSet::with_capacity(self.links.len());
6464+ for link in &self.links {
6565+ if let ParsedLink::Internal(id) = link {
6666+ out.insert(*id);
6767+ }
6868+ }
6969+ out
7070+ }
7171+}
7272+7373+pub(crate) fn parse(s: &str) -> Option<ParsedTask> {
7474+ let mut state: Vec<ParserState> = Vec::new();
7575+ let mut out = String::with_capacity(s.len());
7676+ let mut stream = s.char_indices().peekable();
7777+ let mut links = Vec::new();
7878+ let mut last = '\0';
7979+ use ParserState::*;
8080+ loop {
8181+ let state_last = state.last().cloned();
8282+ match stream.next() {
8383+ // there will always be an op code in the stack
8484+ Some((char_pos, c)) => {
8585+ out.push(c);
8686+ let end = out.len() - 1;
8787+ match (last, c, state_last) {
8888+ ('[', '[', _) => {
8989+ state.push(InternalLink(end, char_pos));
9090+ }
9191+ (']', ']', Some(InternalLink(il, s_pos))) => {
9292+ state.pop();
9393+ let contents = s.get(s_pos + 1..char_pos - 1)?;
9494+ if let Ok(id) = Id::from_str(contents) {
9595+ let linktext = format!(
9696+ "{}{}",
9797+ contents.purple(),
9898+ super_num(links.len() + 1).purple()
9999+ );
100100+ out.replace_range(il - 1..out.len(), &linktext);
101101+ links.push(ParsedLink::Internal(id));
102102+ } else {
103103+ panic!("Internal link is not a valid id: {contents}");
104104+ }
105105+ }
106106+ (last, '[', _) if is_boundary(last) => {
107107+ state.push(Linktext(end, char_pos));
108108+ }
109109+ (']', '(', Some(Linktext(_, _))) => {
110110+ state.push(Link(end, char_pos));
111111+ }
112112+ (')', c, Some(Link(_, _))) if is_boundary(c) => {
113113+ // TODO: this needs to be updated to use `s` instead of `out` for position
114114+ // parsing
115115+ let linkpos = if let Link(lp, _) = state.pop().unwrap() {
116116+ lp
117117+ } else {
118118+ // remove the linktext state, it is always present.
119119+ state.pop();
120120+ continue;
121121+ };
122122+ let linktextpos = if let Linktext(lt, _) = state.pop().unwrap() {
123123+ lt
124124+ } else {
125125+ continue;
126126+ };
127127+ let linktext = format!(
128128+ "{}{}",
129129+ out.get(linktextpos + 1..linkpos - 1)?.blue(),
130130+ super_num(links.len() + 1).purple()
131131+ );
132132+ let link = out.get(linkpos + 1..end - 1)?;
133133+ if let Ok(url) = Url::parse(link) {
134134+ links.push(ParsedLink::External(url));
135135+ out.replace_range(linktextpos..end, &linktext);
136136+ }
137137+ }
138138+ ('>', c, Some(RawLink(hl, s_pos)))
139139+ if is_boundary(c) && s_pos != char_pos - 1 =>
140140+ {
141141+ state.pop();
142142+ let link = s.get(s_pos + 1..char_pos - 1)?;
143143+ if let Ok(url) = Url::parse(link) {
144144+ let linktext =
145145+ format!("{}{}", link.blue(), super_num(links.len() + 1).purple());
146146+ links.push(ParsedLink::External(url));
147147+ out.replace_range(hl..end, &linktext);
148148+ }
149149+ }
150150+ (last, '<', _) if is_boundary(last) => {
151151+ state.push(RawLink(end, char_pos));
152152+ }
153153+ ('=', c, Some(Highlight(hl, s_pos)))
154154+ if is_boundary(c) && s_pos != char_pos - 1 =>
155155+ {
156156+ state.pop();
157157+ out.replace_range(
158158+ hl..end,
159159+ &s.get(s_pos + 1..char_pos - 1)?.reversed().to_string(),
160160+ );
161161+ }
162162+ (last, '=', _) if is_boundary(last) => {
163163+ state.push(Highlight(end, char_pos));
164164+ }
165165+ (last, '*', _) if is_boundary(last) => {
166166+ state.push(Italics(end, char_pos));
167167+ }
168168+ ('*', c, Some(Italics(il, s_pos)))
169169+ if is_boundary(c) && s_pos != char_pos - 1 =>
170170+ {
171171+ state.pop();
172172+ out.replace_range(
173173+ il..end,
174174+ &s.get(s_pos + 1..char_pos - 1)?.italic().to_string(),
175175+ );
176176+ }
177177+ (last, '!', _) if is_boundary(last) => {
178178+ state.push(Bold(end, char_pos));
179179+ }
180180+ ('!', c, Some(Bold(il, s_pos))) if is_boundary(c) && s_pos != char_pos - 1 => {
181181+ state.pop();
182182+ out.replace_range(
183183+ il..end,
184184+ &s.get(s_pos + 1..char_pos - 1)?.bold().to_string(),
185185+ );
186186+ }
187187+ (last, '_', _) if is_boundary(last) => {
188188+ state.push(Underline(end, char_pos));
189189+ }
190190+ ('_', c, Some(Underline(il, s_pos)))
191191+ if is_boundary(c) && s_pos != char_pos - 1 =>
192192+ {
193193+ state.pop();
194194+ out.replace_range(
195195+ il..end,
196196+ &s.get(s_pos + 1..char_pos - 1)?.underline().to_string(),
197197+ );
198198+ }
199199+ (last, '~', _) if is_boundary(last) => {
200200+ state.push(Strikethrough(end, char_pos));
201201+ }
202202+ ('~', c, Some(Strikethrough(il, s_pos)))
203203+ if is_boundary(c) && s_pos != char_pos - 1 =>
204204+ {
205205+ state.pop();
206206+ out.replace_range(
207207+ il..end,
208208+ &s.get(s_pos + 1..char_pos - 1)?.strikethrough().to_string(),
209209+ );
210210+ }
211211+ ('`', c, Some(InlineBlock(hl, s_pos)))
212212+ if is_boundary(c) && s_pos != char_pos - 1 =>
213213+ {
214214+ out.replace_range(
215215+ hl..end,
216216+ &s.get(s_pos + 1..char_pos - 1)?.green().to_string(),
217217+ );
218218+ }
219219+ (last, '`', _) if is_boundary(last) => {
220220+ state.push(InlineBlock(end, char_pos));
221221+ }
222222+ _ => (),
223223+ }
224224+ if c == '\n' || c == '\r' {
225225+ state.clear();
226226+ }
227227+ last = c;
228228+ }
229229+ None => break,
230230+ }
231231+ }
232232+ Some(ParsedTask {
233233+ content: out,
234234+ links,
235235+ })
236236+}
237237+238238+/// Converts a unsigned integer into a superscripted string
239239+fn super_num(num: usize) -> String {
240240+ let num_str = num.to_string();
241241+ let mut out = String::with_capacity(num_str.len());
242242+ for char in num_str.chars() {
243243+ out.push(match char {
244244+ '0' => 'โฐ',
245245+ '1' => 'ยน',
246246+ '2' => 'ยฒ',
247247+ '3' => 'ยณ',
248248+ '4' => 'โด',
249249+ '5' => 'โต',
250250+ '6' => 'โถ',
251251+ '7' => 'โท',
252252+ '8' => 'โธ',
253253+ '9' => 'โน',
254254+ _ => unreachable!(),
255255+ });
256256+ }
257257+ out
258258+}
259259+260260+#[cfg(test)]
261261+mod test {
262262+ use super::*;
263263+ #[test]
264264+ fn test_highlight() {
265265+ let input = "hello =world=\n";
266266+ let output = parse(input).expect("parse to work");
267267+ assert_eq!("hello \u{1b}[7mworld\u{1b}[0m\n", output.content);
268268+ }
269269+270270+ #[test]
271271+ fn test_highlight_bad() {
272272+ let input = "hello =world\n";
273273+ let output = parse(input).expect("parse to work");
274274+ assert_eq!(input, output.content);
275275+ }
276276+277277+ #[test]
278278+ fn test_link() {
279279+ let input = "hello [world](https://ngp.computer)\n";
280280+ let output = parse(input).expect("parse to work");
281281+ assert_eq!(
282282+ &[ParsedLink::External(
283283+ Url::parse("https://ngp.computer").unwrap()
284284+ )],
285285+ output.links.as_slice()
286286+ );
287287+ assert_eq!(
288288+ "hello \u{1b}[34mworld\u{1b}[0m\u{1b}[35mยน\u{1b}[0m\n",
289289+ output.content
290290+ );
291291+ }
292292+293293+ #[test]
294294+ fn test_link_no_terminal_link() {
295295+ let input = "hello [world](https://ngp.computer\n";
296296+ let output = parse(input).expect("parse to work");
297297+ assert!(output.links.is_empty());
298298+ assert_eq!(input, output.content);
299299+ }
300300+ #[test]
301301+ fn test_link_bad_no_start_link() {
302302+ let input = "hello [world]https://ngp.computer)\n";
303303+ let output = parse(input).expect("parse to work");
304304+ assert!(output.links.is_empty());
305305+ assert_eq!(input, output.content);
306306+ }
307307+ #[test]
308308+ fn test_link_bad_no_link() {
309309+ let input = "hello [world]\n";
310310+ let output = parse(input).expect("parse to work");
311311+ assert!(output.links.is_empty());
312312+ assert_eq!(input, output.content);
313313+ }
314314+315315+ #[test]
316316+ fn test_internal_link_good() {
317317+ let input = "hello [[tsk-123]]\n";
318318+ let output = parse(input).expect("parse to work");
319319+ assert_eq!(&[ParsedLink::Internal(Id(123))], output.links.as_slice());
320320+ assert_eq!(
321321+ "hello \u{1b}[35mtsk-123\u{1b}[0m\u{1b}[35mยน\u{1b}[0m\n",
322322+ output.content
323323+ );
324324+ }
325325+326326+ #[test]
327327+ fn test_internal_link_bad() {
328328+ let input = "hello [[tsk-123";
329329+ let output = parse(input).expect("parse to work");
330330+ assert!(output.links.is_empty());
331331+ assert_eq!(input, output.content);
332332+ }
333333+334334+ #[test]
335335+ fn test_italics() {
336336+ let input = "hello *world*\n";
337337+ let output = parse(input).expect("parse to work");
338338+ assert_eq!("hello \u{1b}[3mworld\u{1b}[0m\n", output.content);
339339+ }
340340+341341+ #[test]
342342+ fn test_italics_bad() {
343343+ let input = "hello *world";
344344+ let output = parse(input).expect("parse to work");
345345+ assert_eq!(input, output.content);
346346+ }
347347+348348+ #[test]
349349+ fn test_bold() {
350350+ let input = "hello !world!\n";
351351+ let output = parse(input).expect("parse to work");
352352+ assert_eq!("hello \u{1b}[1mworld\u{1b}[0m\n", output.content);
353353+ }
354354+355355+ #[test]
356356+ fn test_bold_bad() {
357357+ let input = "hello !world\n";
358358+ let output = parse(input).expect("parse to work");
359359+ assert_eq!(input, output.content);
360360+ }
361361+362362+ #[test]
363363+ fn test_underline() {
364364+ let input = "hello _world_\n";
365365+ let output = parse(input).expect("parse to work");
366366+ assert_eq!("hello \u{1b}[4mworld\u{1b}[0m\n", output.content);
367367+ }
368368+369369+ #[test]
370370+ fn test_underline_bad() {
371371+ let input = "hello _world\n";
372372+ let output = parse(input).expect("parse to work");
373373+ assert_eq!(input, output.content);
374374+ }
375375+376376+ #[test]
377377+ fn test_strikethrough() {
378378+ let input = "hello ~world~\n";
379379+ let output = parse(input).expect("parse to work");
380380+ assert_eq!("hello \u{1b}[9mworld\u{1b}[0m\n", output.content);
381381+ }
382382+383383+ #[test]
384384+ fn test_strikethrough_bad() {
385385+ let input = "hello ~world\n";
386386+ let output = parse(input).expect("parse to work");
387387+ assert_eq!(input, output.content);
388388+ }
389389+390390+ #[test]
391391+ fn test_inlineblock() {
392392+ let input = "hello `world`\n";
393393+ let output = parse(input).expect("parse to work");
394394+ assert_eq!("hello \u{1b}[32mworld\u{1b}[0m\n", output.content);
395395+ }
396396+397397+ #[test]
398398+ fn test_inlineblock_bad() {
399399+ let input = "hello `world\n";
400400+ let output = parse(input).expect("parse to work");
401401+ assert_eq!(input, output.content);
402402+ }
403403+404404+ #[test]
405405+ fn test_multiple_styles() {
406406+ let input = "hello *italic* ~strikethrough~ !bold!\n";
407407+ let output = parse(input).expect("parse to work");
408408+ assert_eq!(
409409+ "hello \u{1b}[3mitalic\u{1b}[0m \u{1b}[9mstrikethrough\u{1b}[0m \u{1b}[1mbold\u{1b}[0m\n",
410410+ output.content
411411+ );
412412+ }
413413+}