Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .config/jp/tools/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ mod list_files;
mod modify_file;
mod move_file;
mod read_file;
mod utils;
pub(crate) mod utils;

use create_file::fs_create_file;
use delete_file::fs_delete_file;
Expand Down
2 changes: 2 additions & 0 deletions .config/jp/tools/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod debug_jp;
mod fs;
mod git;
mod github;
mod markdown;
mod plan;
mod ticket;
mod unix;
Expand All @@ -26,6 +27,7 @@ pub async fn run(ctx: Context, t: Tool) -> util::ToolResult {
s if s.starts_with("debug_jp_") => debug_jp::run(ctx, t).await,
s if s.starts_with("web_") => web::run(ctx, t).await,
s if s.starts_with("git_") => git::run(ctx, t).await,
s if s.starts_with("markdown_") => markdown::run(ctx, t),
s if s.starts_with("unix_") => unix::run(ctx, t),
s if s.starts_with("ticket_") => ticket::run(ctx, t),
"plan" => plan::run(ctx, t),
Expand Down
136 changes: 136 additions & 0 deletions .config/jp/tools/src/markdown.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! The `markdown_format` tool: reflow standalone Markdown files with `comfort`.
//!
//! Covers the `.md` files that live outside Rust sources — RFDs, tickets,
//! READMEs, the docs site.
//! Doc comments inside `.rs` files are `cargo_format`'s territory.
//!
//! The `comfort` flags match the `fmt-markdown-ci` recipe in the justfile, so a
//! file this tool leaves alone is a file CI accepts.

use std::collections::BTreeSet;

use jp_tool::Context;

use crate::{
Tool,
fs::utils::resolve_workspace_path,
util::{
OneOrMany, ToolResult, error,
runner::{DuctProcessRunner, ProcessOutput, ProcessRunner},
truncate, unknown_tool,
},
};

/// Cap for the formatted-file listing embedded in a tool result.
///
/// A workspace-wide run after a broad edit can touch hundreds of files, and the
/// head of the list is what the reader needs.
const MAX_LISTING_BYTES: usize = 32_000;

#[expect(
clippy::needless_pass_by_value,
reason = "consistent with other module run fns"
)]
pub fn run(ctx: Context, t: Tool) -> ToolResult {
match t.name.trim_start_matches("markdown_") {
"format" => markdown_format(&ctx, t.opt("paths")?),
_ => unknown_tool(t),
}
}

fn markdown_format(ctx: &Context, paths: Option<OneOrMany<String>>) -> ToolResult {
markdown_format_impl(ctx, paths, &DuctProcessRunner)
}

fn markdown_format_impl<R: ProcessRunner>(
ctx: &Context,
paths: Option<OneOrMany<String>>,
runner: &R,
) -> ToolResult {
let selected = match select_paths(ctx, paths.unwrap_or_default().as_slice()) {
Ok(selected) => selected,
Err(message) => return error(message),
};

let mut args = vec![
"--list-changed",
"--format-markdown",
"--reference-links",
"--prune-reference-links",
"--language",
"markdown",
];
if selected.is_empty() {
args.push("--workspace");
} else {
args.extend(selected.iter().map(String::as_str));
}

let ProcessOutput {
stdout,
stderr,
status,
} = runner.run("comfort", &args, &ctx.root)?;

if !status.is_success() {
return error(format!(
"comfort failed: {}",
truncate(&stderr, MAX_LISTING_BYTES)
));
}

// Workspace runs report absolute paths; strip the root so the listing reads
// the way the rest of the tools spell a path.
let mut files: BTreeSet<String> = BTreeSet::new();
for line in stdout.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
files.insert(
trimmed
.trim_start_matches(ctx.root.as_str())
.trim_start_matches('/')
.to_owned(),
);
}
}

if files.is_empty() {
Ok("No files to format.".into())
} else {
let listing = files.into_iter().collect::<Vec<_>>().join("\n- ");
Ok(format!(
"Formatted files:\n- {}",
truncate(&listing, MAX_LISTING_BYTES)
)
.into())
}
}

/// Validate each requested path and return its workspace-relative form.
///
/// An empty request yields an empty selection, which the caller turns into a
/// whole-workspace run.
///
/// `comfort` walks a directory and filters it down to Markdown extensions, but
/// takes a named file as-is whatever its extension — so a file that isn't
/// Markdown is refused here rather than rewritten as Markdown.
fn select_paths(ctx: &Context, paths: &[String]) -> Result<Vec<String>, String> {
paths
.iter()
.map(|path| {
let resolved = resolve_workspace_path(&ctx.root, path, ctx.access.as_ref())?;
let markdown = matches!(resolved.relative.extension(), Some("md" | "markdown"));
if !markdown && !resolved.absolute.is_dir() {
return Err(format!(
"'{path}' is not a Markdown file or a directory. Only `.md` and `.markdown` \
files are formatted."
));
}
Ok(resolved.relative.into_string())
})
.collect()
}

#[cfg(test)]
#[path = "markdown_tests.rs"]
mod tests;
197 changes: 197 additions & 0 deletions .config/jp/tools/src/markdown_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
use std::fs;

use camino_tempfile::{Utf8TempDir, tempdir};
use jp_tool::{Action, Outcome};
use pretty_assertions::assert_eq;

use super::*;
use crate::util::runner::{ExitCode, MockProcessRunner, ProcessOutput};

/// The flags every invocation carries, in the order the tool builds them.
const BASE_ARGS: &[&str] = &[
"--list-changed",
"--format-markdown",
"--reference-links",
"--prune-reference-links",
"--language",
"markdown",
];

fn ctx() -> (Utf8TempDir, Context) {
let dir = tempdir().unwrap();
let ctx = Context {
root: dir.path().to_owned(),
action: Action::Run,
access: None,
workspace_id: "test".into(),
conversation_id: "test".into(),
};

(dir, ctx)
}

fn paths(paths: &[&str]) -> OneOrMany<String> {
OneOrMany::Many(paths.iter().map(|p| (*p).to_owned()).collect())
}

#[test]
fn no_paths_formats_the_whole_workspace() {
let (_dir, ctx) = ctx();
let expected_args = [BASE_ARGS, &["--workspace"]].concat();
let runner = MockProcessRunner::builder()
.expect("comfort")
.args(&expected_args)
.returns_success("");

let result = markdown_format_impl(&ctx, None, &runner).unwrap();
assert_eq!(result.unwrap_content(), "No files to format.");
}

#[test]
fn explicit_paths_replace_the_workspace_scope() {
let (_dir, ctx) = ctx();
let expected_args = [BASE_ARGS, &["docs/rfd/drafts/D01-knowledge-base.md"]].concat();
let runner = MockProcessRunner::builder()
.expect("comfort")
.args(&expected_args)
.returns_success("");

let result = markdown_format_impl(
&ctx,
Some(paths(&["docs/rfd/drafts/D01-knowledge-base.md"])),
&runner,
)
.unwrap();
assert_eq!(result.unwrap_content(), "No files to format.");
}

#[test]
fn a_directory_is_passed_through_for_comfort_to_walk() {
let (dir, ctx) = ctx();
fs::create_dir_all(dir.path().join("docs/rfd")).unwrap();
let expected_args = [BASE_ARGS, &["docs/rfd"]].concat();
let runner = MockProcessRunner::builder()
.expect("comfort")
.args(&expected_args)
.returns_success("");

let result = markdown_format_impl(&ctx, Some(paths(&["docs/rfd"])), &runner).unwrap();
assert_eq!(result.unwrap_content(), "No files to format.");
}

#[test]
fn changed_files_are_root_stripped_deduplicated_and_sorted() {
let (_dir, ctx) = ctx();
let stdout = format!(
"{root}/docs/usage.md\n{root}/README.md\n{root}/docs/usage.md\n",
root = ctx.root
);
let runner = MockProcessRunner::builder()
.expect("comfort")
.returns_success(stdout);

let result = markdown_format_impl(&ctx, None, &runner).unwrap();
assert_eq!(
result.unwrap_content(),
"Formatted files:\n- README.md\n- docs/usage.md"
);
}

#[test]
fn a_non_markdown_file_is_refused_without_running_comfort() {
let (_dir, ctx) = ctx();
let runner = MockProcessRunner::never_called();

let result =
markdown_format_impl(&ctx, Some(paths(&["crates/jp_cli/src/main.rs"])), &runner).unwrap();
match result {
Outcome::Error { message, .. } => assert_eq!(
message,
"'crates/jp_cli/src/main.rs' is not a Markdown file or a directory. Only `.md` and \
`.markdown` files are formatted."
),
_ => panic!("Expected Outcome::Error, got: {result:?}"),
}
}

#[test]
fn a_path_outside_the_workspace_is_refused_without_running_comfort() {
let (_dir, ctx) = ctx();
let runner = MockProcessRunner::never_called();

let result =
markdown_format_impl(&ctx, Some(paths(&["../elsewhere/notes.md"])), &runner).unwrap();
match result {
Outcome::Error { message, .. } => {
assert_eq!(message, "Path must not escape the workspace root.");
}
_ => panic!("Expected Outcome::Error, got: {result:?}"),
}
}

#[test]
fn one_bad_path_refuses_the_whole_request() {
let (_dir, ctx) = ctx();
let runner = MockProcessRunner::never_called();

let result = markdown_format_impl(
&ctx,
Some(paths(&["docs/usage.md", "crates/jp_cli/src/main.rs"])),
&runner,
)
.unwrap();
match result {
Outcome::Error { message, .. } => assert!(
message.starts_with("'crates/jp_cli/src/main.rs' is not a Markdown file"),
"got: {message}"
),
_ => panic!("Expected Outcome::Error, got: {result:?}"),
}
}

#[test]
fn comfort_failure_is_reported() {
let (_dir, ctx) = ctx();
let runner = MockProcessRunner::builder()
.expect("comfort")
.returns(ProcessOutput {
stdout: String::new(),
stderr: "comfort: parse error".to_owned(),
status: ExitCode::from_code(2),
});

let result = markdown_format_impl(&ctx, None, &runner).unwrap();
match result {
Outcome::Error { message, .. } => {
assert_eq!(message, "comfort failed: comfort: parse error");
}
_ => panic!("Expected Outcome::Error, got: {result:?}"),
}
}

#[test]
fn formatted_file_listing_is_bounded() {
let (_dir, ctx) = ctx();
let stdout = (0..4_000)
.map(|i| format!("{root}/docs/generated/file_{i}.md", root = ctx.root))
.collect::<Vec<_>>()
.join("\n");
let runner = MockProcessRunner::builder()
.expect("comfort")
.returns_success(stdout);

let content = markdown_format_impl(&ctx, None, &runner)
.unwrap()
.unwrap_content();

assert!(
content.len() < MAX_LISTING_BYTES + 200,
"listing grew to {} bytes",
content.len()
);
assert!(
content.contains("[Truncated: showing"),
"got tail: {}",
&content[content.len() - 100..]
);
}
6 changes: 4 additions & 2 deletions .jp/config/skill/rfd.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ attachments = [
]

[conversation.tools]
markdown_format = { enable = true }

rfd_draft = { enable = true, run = "unattended" }
rfd_promote = { enable = true, run = "unattended" }
rfd_renumber = { enable = true, run = "unattended" }
Expand Down Expand Up @@ -117,8 +119,8 @@ hedging language and filler.
an RFD, use `fs_modify_file` to make targeted edits. The user will be \
asked to confirm each modification before it is applied.

8. **Format before hashing**: Run `cargo_format` before computing an \
RFD's hash for `docs/.vitepress/rfd-summaries.json` — `comfort` reflows \
8. **Format before hashing**: Run `markdown_format` before computing an \
RFD's hash for `docs/.vitepress/rfd-summaries.json` — it reflows \
markdown paragraphs, so a hash taken first goes stale.\
"""

Expand Down
6 changes: 6 additions & 0 deletions .jp/config/skill/writing.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ You have been given the technical-writing skill. You produce clear, scannable, \
developer-facing documentation for command-line developer tools. You combine \
the read-files skill with documentation-research methodology to verify claims \
against the actual code, tests, and existing docs before writing about them.

`markdown_format` reflows the Markdown files you write and canonicalizes their \
structure and reference links. Run it after editing a `.md` file.
"""

[conversation.tools]
markdown_format = { enable = true }

[[assistant.instructions]]
title = "Documentation Research"
items = [
Expand Down
Loading
Loading