use regex::Regex;
use std::borrow::Cow;
use supports_hyperlinks::Stream;
use super::{DateTimeInfo, Duration, Task, Unit, priority};
use crate::{color, comments::Comment, config::Config, errors::Error, projects::Project, time};
pub fn content(task: &Task, config: &Config) -> String {
let content = match task.priority {
priority::Priority::Low => color::blue_string(&task.content),
priority::Priority::Medium => color::yellow_string(&task.content),
priority::Priority::High => color::red_string(&task.content),
priority::Priority::None => color::normal_string(&task.content),
};
if hyperlinks_disabled(config) {
content
} else {
create_links(&content)
}
}
pub async fn project(task: &Task, config: &Config, buffer: &str) -> Result<String, Error> {
let project_icon = color::purple_string("#");
let maybe_project = config
.projects()
.await?
.into_iter()
.filter(|p| p.id == task.project_id)
.collect::<Vec<Project>>();
let text = match maybe_project.first() {
Some(Project { name, .. }) => format!("\n{buffer}{project_icon} {name}"),
None => {
let command = color::cyan_string("tod project import --auto");
format!(
"\n{buffer}{project_icon} Project not in config\nUse {command} to import missing projects"
)
}
};
Ok(text)
}
pub fn hyperlinks_disabled(config: &Config) -> bool {
config.disable_links || !supports_hyperlinks::on(Stream::Stdout)
}
pub fn labels(task: &Task) -> String {
format!(" {} {}", color::purple_string("@"), task.labels.join(" "))
}
pub fn due(task: &Task, config: &Config, buffer: &str) -> String {
let due_icon = color::purple_string("!");
let recurring_icon = color::purple_string("↻");
match &task.datetimeinfo(config) {
Ok(DateTimeInfo::Date {
date,
is_recurring,
string,
}) => {
let recurring_icon = if *is_recurring {
format!(" {recurring_icon} {string}")
} else {
String::new()
};
let date_string = time::date_to_string(date, config).unwrap_or_default();
format!("\n{buffer}{due_icon} {date_string}{recurring_icon}")
}
Ok(DateTimeInfo::DateTime {
datetime,
is_recurring,
string,
}) => {
let recurring_icon = if *is_recurring {
format!(" {recurring_icon} {string}")
} else {
String::new()
};
let datetime_string = time::datetime_to_string(datetime, config).unwrap_or_default();
let duration_string = match task.duration {
None => String::new(),
Some(Duration {
amount: 1,
unit: Unit::Day,
}) => " for 1 day".into(),
Some(Duration {
amount,
unit: Unit::Day,
}) => format!(" for {amount} days"),
Some(Duration {
amount,
unit: Unit::Minute,
}) => format!(" for {amount} min"),
};
format!("\n{buffer}{due_icon} {datetime_string}{duration_string}{recurring_icon}")
}
Ok(DateTimeInfo::NoDateTime) => String::new(),
Err(e) => e.to_string(),
}
}
fn create_links(content: &str) -> String {
let link_regex =
Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").expect("invalid markdown link regex pattern");
let result = link_regex.replace_all(content, |caps: ®ex::Captures| {
let text = &caps[1];
let url = &caps[2];
Cow::from(format!("\x1b]8;;{url}\x07[{text}]\x1b]8;;\x07"))
});
result.into_owned()
}
pub fn maybe_format_url(url: &str, config: &Config) -> String {
if hyperlinks_disabled(config) {
return url.to_string();
}
format!("\x1B]8;;{url}\x07[{url}]\x1B]8;;\x07")
}
pub fn number_comments(quantity: usize) -> String {
let comment_icon = color::purple_string("★");
if quantity == 1 {
return format!("\n{comment_icon} 1 comment");
}
format!("\n{comment_icon} {quantity} comments")
}
pub fn maybe_format_task_id(task_id: &str, config: &Config) -> String {
let url = format!("https://app.todoist.com/app/task/{task_id}");
if hyperlinks_disabled(config) {
url
} else {
format!("\x1B]8;;{url}\x07[link]\x1B]8;;\x07")
}
}
pub async fn render_comments(config: &Config, comments: Vec<Comment>) -> Result<String, Error> {
let comment_icon = color::purple_string("★");
let mut comments = comments
.iter()
.map(|c| {
c.fmt(config)
.unwrap_or_else(|e| format!("Failed to render comment: {e:?}"))
})
.collect::<Vec<String>>();
comments.reverse();
let comments = comments.join("\n\n");
let mut formatted_string = format!("\n\n{comment_icon} Comments {comment_icon}\n\n{comments}");
let max_comment_length: usize = config.max_comment_length().try_into()?;
if formatted_string.len() > max_comment_length {
formatted_string.truncate(max_comment_length);
formatted_string.push_str("...");
};
Ok(formatted_string)
}
#[cfg(test)]
mod tests {
use crate::test;
use crate::test::responses::ResponseFromFile;
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_create_links() {
assert_eq!(create_links("hello"), String::from("hello"));
assert_eq!(
create_links("This is text [Google](https://www.google.com/)"),
String::from("This is text \x1b]8;;https://www.google.com/\x07[Google]\x1b]8;;\x07")
);
}
#[test]
fn test_task_url_enabled() {
let config = Config::default();
if !supports_hyperlinks::on(Stream::Stdout) {
eprintln!("Skipping test: hyperlinks not supported in this environment");
return;
}
assert_eq!(
maybe_format_task_id("1", &config),
String::from("\x1B]8;;https://app.todoist.com/app/task/1\x1B\\[link]\x1B]8;;\x1B\\")
);
}
#[test]
fn test_task_url_disabled() {
let mut config = Config::default();
config.disable_links = true;
assert_eq!(
maybe_format_task_id("1", &config),
String::from("https://app.todoist.com/app/task/1")
);
}
#[tokio::test]
async fn test_comments() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock(
"GET",
"/api/v1/comments/?task_id=6Xqhv4cwxgjwG9w8&limit=200",
)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(ResponseFromFile::CommentsAllTypes.read().await)
.create_async()
.await;
let config = test::fixtures::config().await.with_mock_url(server.url());
let comments = vec![test::fixtures::comment()];
let comments = render_comments(&config, comments)
.await
.expect("expected value or result, got None or Err");
assert_matches!(
comments.as_str(),
"\n\n★ Comments ★\n\nPosted 2016-09-22 00:00:00 PDT\nNeed one bottle of milk"
);
mock.expect(1);
}
#[test]
fn test_create_links_multiple_and_edge_cases() {
let input = "Links: [Rust](https://www.rust-lang.org/) and [GitHub](https://github.com/)";
let expected = "Links: \x1b]8;;https://www.rust-lang.org/\x07[Rust]\x1b]8;;\x07 and \x1b]8;;https://github.com/\x07[GitHub]\x1b]8;;\x07";
assert_eq!(create_links(input), expected);
let input = "Check this out: [Example](https://example.com)";
let expected = "Check this out: \x1b]8;;https://example.com\x07[Example]\x1b]8;;\x07";
assert_eq!(create_links(input), expected);
assert_eq!(create_links("No links here."), "No links here.");
assert_eq!(
create_links("[Broken link](not a url"),
"[Broken link](not a url"
);
}
#[tokio::test]
async fn test_format_url_hyperlinks_enabled() {
let url = "https://www.rust-lang.org/";
let expected =
"\x1B]8;;https://www.rust-lang.org/\x1B\\[https://www.rust-lang.org/]\x1B]8;;\x1B\\";
let config = Config::default();
if !supports_hyperlinks::on(Stream::Stdout) {
eprintln!("Skipping test: hyperlinks not supported in this environment");
return;
}
assert_eq!(maybe_format_url(url, &config), expected);
}
#[test]
fn test_format_url_hyperlinks_disabled() {
let url = "https://www.rust-lang.org/";
let mut config = Config::default();
config.disable_links = true;
assert_eq!(maybe_format_url(url, &config), url);
}
}