use crate::config::{self, Config};
use crate::{dirs, file};
use color_eyre::eyre::bail;
use std::path::{Path, PathBuf};
const TASK_PLACEHOLDER_START: &str = "<!-- mise-tasks -->";
const TASK_PLACEHOLDER_END: &str = "<!-- /mise-tasks -->";
#[derive(Debug, usage_rs::Args)]
#[usage(
verbatim_doc_comment,
example(
r###"mise generate task-docs --style detailed
mise generate task-docs --output TASKS.md"###
),
example(
r###"mise generate task-docs --inject --output README.md"###,
help = r###"README.md must already contain both mise-tasks marker comments"###
)
)]
pub(super) struct TaskDocs {
#[usage(long, short, verbatim_doc_comment)]
inject: bool,
#[usage(long, short = 'I', verbatim_doc_comment)]
index: bool,
#[usage(long, short, verbatim_doc_comment)]
multi: bool,
#[usage(long, short, verbatim_doc_comment)]
output: Option<PathBuf>,
#[usage(long, short, verbatim_doc_comment, value_hint = usage_rs::ValueHint::DirPath)]
root: Option<PathBuf>,
#[usage(long, short, verbatim_doc_comment, value_enum, default = "simple")]
style: TaskDocsStyle,
}
#[derive(Debug, Default, Clone, usage_rs::ValueEnum)]
enum TaskDocsStyle {
#[default]
#[usage()]
Simple,
#[usage()]
Detailed,
}
impl TaskDocs {
pub(super) async fn run(self) -> eyre::Result<()> {
let config = Config::get().await?;
let dir = dirs::CWD.as_ref().unwrap();
let tasks = config::load_tasks_in_dir(&config, dir, &config.config_files).await?;
let visible_tasks: Vec<_> = tasks.iter().filter(|t| !t.hide).collect();
if let Some(output) = &self.output {
if self.multi {
if output.is_dir() {
let mut index = if self.index {
Some(String::from("# Tasks\n\n"))
} else {
None
};
for task in &visible_tasks {
let filename = format!("{}.md", task.name.replace([':', '/'], "-"));
let path = output.join(&filename);
file::write(&path, &task.render_markdown(&config).await?)?;
miseprintln!("Wrote to {}", file::display_path(&path));
if let Some(index) = &mut index {
let desc = if task.description.is_empty() {
String::new()
} else {
format!(" - {}", task.description)
};
index.push_str(&format!("- [{}](./{filename}){desc}\n", task.name));
}
}
if let Some(index) = index {
if visible_tasks
.iter()
.any(|t| t.name.replace([':', '/'], "-") == "index")
{
warn!("task named \"index\" will be overwritten by index.md");
}
let path = output.join("index.md");
file::write(&path, &index)?;
miseprintln!("Wrote to {}", file::display_path(&path));
}
} else {
return Err(eyre::eyre!(
"`--output` must be a directory when `--multi` is set"
));
}
} else {
let mut out = vec![];
for task in &visible_tasks {
out.push(task.render_markdown(&config).await?);
}
let mut doc = String::new();
for task in out {
doc.push_str(&task);
doc.push_str("\n\n");
}
if self.inject {
doc = format!("\n{}\n", doc.trim());
let contents = file::read_to_string(output)?;
let contents = inject_task_docs(&contents, &doc, output)?;
file::write(output, &contents)?;
miseprintln!("Wrote to {}", file::display_path(output));
} else {
doc = format!("{}\n", doc.trim());
file::write(output, &doc)?;
miseprintln!("Wrote to {}", file::display_path(output));
}
}
} else {
let mut out = vec![];
for task in &visible_tasks {
out.push(task.render_markdown(&config).await?);
}
miseprintln!("{}", out.join("\n\n").trim());
}
Ok(())
}
}
fn inject_task_docs(contents: &str, doc: &str, output: &Path) -> eyre::Result<String> {
let Some(start) = contents.find(TASK_PLACEHOLDER_START) else {
bail!(
"{} does not contain the `{TASK_PLACEHOLDER_START}` marker required by --inject, add:\n\n{TASK_PLACEHOLDER_START}\n{TASK_PLACEHOLDER_END}",
file::display_path(output)
);
};
let body_start = start + TASK_PLACEHOLDER_START.len();
let Some(end) = contents[body_start..]
.find(TASK_PLACEHOLDER_END)
.map(|e| e + body_start)
else {
bail!(
"{} contains `{TASK_PLACEHOLDER_START}` but no `{TASK_PLACEHOLDER_END}` after it, --inject requires both markers",
file::display_path(output)
);
};
let mut contents = contents.to_string();
contents.replace_range(body_start..end, doc);
Ok(contents)
}
#[cfg(test)]
mod tests {
use super::{TASK_PLACEHOLDER_END, TASK_PLACEHOLDER_START, inject_task_docs};
use std::path::Path;
fn inject(contents: &str) -> eyre::Result<String> {
inject_task_docs(contents, "\n## `task`\n", Path::new("README.md"))
}
#[test]
fn injects_between_markers() {
let contents = format!(
"# Title\n\n{TASK_PLACEHOLDER_START}\nold\n{TASK_PLACEHOLDER_END}\n\ntrailer\n"
);
assert_eq!(
inject(&contents).unwrap(),
format!(
"# Title\n\n{TASK_PLACEHOLDER_START}\n## `task`\n{TASK_PLACEHOLDER_END}\n\ntrailer\n"
)
);
}
#[test]
fn errors_without_start_marker() {
let err = inject("# My Project\n\nImportant paragraph that must survive.\n").unwrap_err();
assert!(err.to_string().contains(TASK_PLACEHOLDER_START));
}
#[test]
fn errors_on_file_shorter_than_marker() {
assert!(inject("hi\n").is_err());
}
#[test]
fn errors_when_byte_offset_is_not_a_char_boundary() {
assert!(inject("日本語のドキュメント\n").is_err());
}
#[test]
fn errors_without_end_marker() {
let contents = format!("# Title\n\n{TASK_PLACEHOLDER_START}\nold\n");
let err = inject(&contents).unwrap_err();
assert!(err.to_string().contains(TASK_PLACEHOLDER_END));
}
#[test]
fn errors_when_end_marker_precedes_start_marker() {
let contents = format!("{TASK_PLACEHOLDER_END}\nold\n{TASK_PLACEHOLDER_START}\n");
assert!(inject(&contents).is_err());
}
}