You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
838 lines
29 KiB
Rust
838 lines
29 KiB
Rust
//! File I/O and directory traversal for ticket storage.
|
|
//!
|
|
//! Tickets are stored in `.nbd/tickets/` relative to the project root.
|
|
//! Each ticket is a single file named `{id}.{ext}`, where the extension
|
|
//! depends on the [`FileFormat`]:
|
|
//!
|
|
//! | Format | Extension | Description |
|
|
//! |---|---|---|
|
|
//! | [`FileFormat::Json`] | `.json` | Pretty-printed JSON (default) |
|
|
//! | [`FileFormat::Markdown`] | `.md` | Markdown body with TOML frontmatter |
|
|
//! | [`FileFormat::Toml`] | `.toml` | TOML |
|
|
//! | [`FileFormat::Jsonb`] | `.jsonb` | CBOR binary |
|
|
//!
|
|
//! The root is discovered by walking up from the current working directory
|
|
//! until a `.nbd/` directory is found, mirroring how `git` locates `.git/`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::fs;
|
|
|
|
use crate::filter::TicketFilter;
|
|
use crate::ticket::{Status, Ticket, TicketType};
|
|
|
|
/// Convenience alias for fallible operations in this module.
|
|
///
|
|
/// The error type is a heap-allocated trait object so that both `io::Error`
|
|
/// and `serde_json::Error` (and any other `std::error::Error` implementor)
|
|
/// can be returned with `?` without additional wrapping.
|
|
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
|
|
|
/// Convert a string message into a boxed error, with an unambiguous type.
|
|
fn msg_err(s: String) -> Box<dyn std::error::Error + Send + Sync> {
|
|
s.into()
|
|
}
|
|
|
|
// ── FileFormat ────────────────────────────────────────────────────────────────
|
|
|
|
/// The on-disk serialisation format for a ticket file.
|
|
///
|
|
/// The format is determined by the file extension when reading, and must be
|
|
/// supplied explicitly when writing a new file. `Json` is the default.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```
|
|
/// use nbd::store::FileFormat;
|
|
/// assert_eq!(FileFormat::Json.extension(), "json");
|
|
/// assert_eq!(FileFormat::Markdown.extension(), "md");
|
|
/// ```
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum FileFormat {
|
|
/// Pretty-printed JSON (`.json`). The original and default format.
|
|
#[default]
|
|
Json,
|
|
/// Markdown file with TOML frontmatter (`.md`).
|
|
///
|
|
/// The ticket body is stored as the file content after the closing `+++`
|
|
/// delimiter; all other fields live in the TOML frontmatter block.
|
|
Markdown,
|
|
/// TOML (`.toml`). All ticket fields except `id` are stored as TOML.
|
|
Toml,
|
|
/// CBOR binary encoding (`.jsonb`). Compact binary alternative to JSON.
|
|
Jsonb,
|
|
}
|
|
|
|
impl FileFormat {
|
|
/// The file extension associated with this format (without a leading dot).
|
|
pub fn extension(self) -> &'static str {
|
|
match self {
|
|
FileFormat::Json => "json",
|
|
FileFormat::Markdown => "md",
|
|
FileFormat::Toml => "toml",
|
|
FileFormat::Jsonb => "jsonb",
|
|
}
|
|
}
|
|
|
|
/// Parse a [`FileFormat`] from a user-supplied string.
|
|
///
|
|
/// Accepts `"json"`, `"md"`, `"toml"`, and `"jsonb"`.
|
|
/// Returns `None` for unrecognised values.
|
|
pub fn from_str(s: &str) -> Option<Self> {
|
|
match s {
|
|
"json" => Some(FileFormat::Json),
|
|
"md" => Some(FileFormat::Markdown),
|
|
"toml" => Some(FileFormat::Toml),
|
|
"jsonb" => Some(FileFormat::Jsonb),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for FileFormat {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.extension())
|
|
}
|
|
}
|
|
|
|
/// File extensions recognised as ticket files, tried in this order on reads.
|
|
const KNOWN_EXTENSIONS: &[&str] = &["json", "md", "toml", "jsonb"];
|
|
|
|
/// Detect the [`FileFormat`] from a file path's extension.
|
|
///
|
|
/// Falls back to [`FileFormat::Json`] for absent or unrecognised extensions.
|
|
pub fn detect_format(path: &Path) -> FileFormat {
|
|
match path.extension().and_then(|e| e.to_str()) {
|
|
Some("json") => FileFormat::Json,
|
|
Some("md") => FileFormat::Markdown,
|
|
Some("toml") => FileFormat::Toml,
|
|
Some("jsonb") => FileFormat::Jsonb,
|
|
_ => FileFormat::Json,
|
|
}
|
|
}
|
|
|
|
// ── Markdown frontmatter helper ───────────────────────────────────────────────
|
|
|
|
/// Ticket metadata stored in TOML frontmatter for `.md` files.
|
|
///
|
|
/// The `body` field is intentionally absent here: in the markdown format the
|
|
/// body is the file content that follows the closing `+++` delimiter.
|
|
#[derive(Serialize, Deserialize)]
|
|
struct MarkdownFrontmatter {
|
|
title: String,
|
|
priority: u8,
|
|
status: Status,
|
|
ticket_type: TicketType,
|
|
dependencies: Vec<String>,
|
|
}
|
|
|
|
// ── Serialisation helpers ─────────────────────────────────────────────────────
|
|
|
|
/// Serialise `ticket` to a pretty-printed JSON string.
|
|
fn serialize_json(ticket: &Ticket) -> Result<String> {
|
|
Ok(serde_json::to_string_pretty(ticket)?)
|
|
}
|
|
|
|
/// Serialise `ticket` to a TOML string.
|
|
fn serialize_toml(ticket: &Ticket) -> Result<String> {
|
|
Ok(toml::to_string(ticket)?)
|
|
}
|
|
|
|
/// Serialise `ticket` to a markdown document with TOML frontmatter.
|
|
///
|
|
/// Format:
|
|
/// ```text
|
|
/// +++
|
|
/// title = "..."
|
|
/// priority = 5
|
|
/// ...
|
|
/// +++
|
|
/// Body content here.
|
|
/// ```
|
|
fn serialize_markdown(ticket: &Ticket) -> Result<String> {
|
|
let fm = MarkdownFrontmatter {
|
|
title: ticket.title.clone(),
|
|
priority: ticket.priority,
|
|
status: ticket.status.clone(),
|
|
ticket_type: ticket.ticket_type.clone(),
|
|
dependencies: ticket.dependencies.clone(),
|
|
};
|
|
let toml_str = toml::to_string(&fm)?;
|
|
Ok(format!("+++\n{toml_str}+++\n{}", ticket.body))
|
|
}
|
|
|
|
/// Serialise `ticket` to CBOR binary.
|
|
fn serialize_jsonb(ticket: &Ticket) -> Result<Vec<u8>> {
|
|
let mut buf = Vec::new();
|
|
ciborium::ser::into_writer(ticket, &mut buf)
|
|
.map_err(|e| msg_err(format!("CBOR serialization error: {e}")))?;
|
|
Ok(buf)
|
|
}
|
|
|
|
// ── Deserialisation helpers ───────────────────────────────────────────────────
|
|
|
|
/// Deserialise a ticket from JSON bytes.
|
|
fn deserialize_json(bytes: &[u8]) -> Result<Ticket> {
|
|
Ok(serde_json::from_slice(bytes)?)
|
|
}
|
|
|
|
/// Deserialise a ticket from TOML bytes.
|
|
fn deserialize_toml(bytes: &[u8]) -> Result<Ticket> {
|
|
let s = std::str::from_utf8(bytes)?;
|
|
Ok(toml::from_str(s)?)
|
|
}
|
|
|
|
/// Deserialise a ticket from a markdown document with TOML frontmatter.
|
|
///
|
|
/// The document must begin with `+++\n`, contain a closing `\n+++\n`, and
|
|
/// have TOML key-value pairs between the two delimiters. Everything after the
|
|
/// closing delimiter becomes the ticket body.
|
|
fn deserialize_markdown(bytes: &[u8]) -> Result<Ticket> {
|
|
let content = std::str::from_utf8(bytes)?;
|
|
|
|
let after_open = content
|
|
.strip_prefix("+++\n")
|
|
.ok_or("markdown ticket must start with '+++\\n'")?;
|
|
|
|
let (fm_str, body) = after_open
|
|
.split_once("\n+++\n")
|
|
.ok_or("markdown ticket is missing a closing '+++' delimiter")?;
|
|
|
|
let fm: MarkdownFrontmatter = toml::from_str(fm_str)?;
|
|
|
|
Ok(Ticket {
|
|
id: String::new(),
|
|
title: fm.title,
|
|
body: body.to_string(),
|
|
priority: fm.priority,
|
|
status: fm.status,
|
|
ticket_type: fm.ticket_type,
|
|
dependencies: fm.dependencies,
|
|
})
|
|
}
|
|
|
|
/// Deserialise a ticket from CBOR binary bytes.
|
|
fn deserialize_jsonb(bytes: &[u8]) -> Result<Ticket> {
|
|
ciborium::de::from_reader(bytes)
|
|
.map_err(|e| msg_err(format!("CBOR deserialization error: {e}")))
|
|
}
|
|
|
|
/// Deserialise `bytes` using the detected `format`.
|
|
fn deserialize_by_format(bytes: &[u8], format: FileFormat) -> Result<Ticket> {
|
|
match format {
|
|
FileFormat::Json => deserialize_json(bytes),
|
|
FileFormat::Markdown => deserialize_markdown(bytes),
|
|
FileFormat::Toml => deserialize_toml(bytes),
|
|
FileFormat::Jsonb => deserialize_jsonb(bytes),
|
|
}
|
|
}
|
|
|
|
// ── Directory helpers ─────────────────────────────────────────────────────────
|
|
|
|
/// Walk upward from `start` until a directory containing `.nbd/` is found.
|
|
///
|
|
/// Returns the first ancestor path (inclusive of `start`) that contains a
|
|
/// `.nbd/` subdirectory, or an error if the filesystem root is reached without
|
|
/// finding one.
|
|
///
|
|
/// This is the low-level, testable variant. Most callers should use
|
|
/// [`find_nbd_root`], which starts from the current working directory.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if no `.nbd/` directory exists in `start` or any of its
|
|
/// ancestors.
|
|
pub fn find_nbd_root_from(start: &Path) -> Result<PathBuf> {
|
|
let mut dir = start;
|
|
loop {
|
|
if dir.join(".nbd").is_dir() {
|
|
return Ok(dir.to_path_buf());
|
|
}
|
|
match dir.parent() {
|
|
Some(parent) => dir = parent,
|
|
None => break,
|
|
}
|
|
}
|
|
Err(
|
|
"could not find .nbd/ directory; create `.nbd/tickets/` in your project root to initialise"
|
|
.into(),
|
|
)
|
|
}
|
|
|
|
/// Walk upward from the current working directory until a `.nbd/` directory is
|
|
/// found.
|
|
///
|
|
/// This is the primary entry point used by all CLI commands. Internally
|
|
/// delegates to [`find_nbd_root_from`].
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the current working directory cannot be determined, or
|
|
/// if no `.nbd/` directory exists in it or any of its ancestors.
|
|
pub fn find_nbd_root() -> Result<PathBuf> {
|
|
let cwd = std::env::current_dir()?;
|
|
find_nbd_root_from(&cwd)
|
|
}
|
|
|
|
/// Return the path to the tickets directory within a project root.
|
|
///
|
|
/// This is a pure path computation — it does not check whether the directory
|
|
/// exists.
|
|
pub fn tickets_dir(root: &Path) -> PathBuf {
|
|
root.join(".nbd").join("tickets")
|
|
}
|
|
|
|
/// Create `.nbd/tickets/` under `root` if it does not already exist.
|
|
///
|
|
/// All intermediate directories (including `.nbd/`) are created as needed.
|
|
/// This should only be called by the `create` command; other commands can
|
|
/// assume the directory already exists.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates any I/O error returned by the filesystem.
|
|
pub async fn ensure_tickets_dir(root: &Path) -> Result<()> {
|
|
let dir = tickets_dir(root);
|
|
fs::create_dir_all(dir).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
|
|
/// Return the path to a specific ticket file for the given `format`.
|
|
///
|
|
/// This is a pure path computation — it does not check whether the file
|
|
/// exists.
|
|
pub fn ticket_path(root: &Path, id: &str, format: FileFormat) -> PathBuf {
|
|
tickets_dir(root).join(format!("{id}.{}", format.extension()))
|
|
}
|
|
|
|
/// Find and return the actual on-disk path of a ticket, trying each known
|
|
/// extension in order.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if no file with a known extension exists for `id`.
|
|
pub async fn find_ticket_path(root: &Path, id: &str) -> Result<PathBuf> {
|
|
let dir = tickets_dir(root);
|
|
for ext in KNOWN_EXTENSIONS {
|
|
let path = dir.join(format!("{id}.{ext}"));
|
|
if path.is_file() {
|
|
return Ok(path);
|
|
}
|
|
}
|
|
Err(format!("ticket '{id}' not found").into())
|
|
}
|
|
|
|
// ── CRUD operations ───────────────────────────────────────────────────────────
|
|
|
|
/// Serialise `ticket` and write it to `.nbd/tickets/{id}.{ext}` using
|
|
/// `format` to determine both the extension and the serialisation.
|
|
///
|
|
/// Overwrites any existing file at the same path. The tickets directory must
|
|
/// already exist; call [`ensure_tickets_dir`] before calling this for a new
|
|
/// ticket.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if serialisation fails or if the file cannot be written.
|
|
pub async fn write_ticket(root: &Path, ticket: &Ticket, format: FileFormat) -> Result<()> {
|
|
let path = ticket_path(root, &ticket.id, format);
|
|
match format {
|
|
FileFormat::Json => {
|
|
let json = serialize_json(ticket)?;
|
|
fs::write(path, json).await?;
|
|
}
|
|
FileFormat::Markdown => {
|
|
let content = serialize_markdown(ticket)?;
|
|
fs::write(path, content).await?;
|
|
}
|
|
FileFormat::Toml => {
|
|
let content = serialize_toml(ticket)?;
|
|
fs::write(path, content).await?;
|
|
}
|
|
FileFormat::Jsonb => {
|
|
let bytes = serialize_jsonb(ticket)?;
|
|
fs::write(path, bytes).await?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve a full 6-character ticket ID from an exact ID or a unique prefix.
|
|
///
|
|
/// If `id_or_prefix` is already an exact match for a ticket on disk (in any
|
|
/// supported format), it is returned unchanged. Otherwise all ticket filenames
|
|
/// whose stem starts with `id_or_prefix` are collected and:
|
|
///
|
|
/// - **0 matches** → error: `"no ticket found matching '{prefix}'"`
|
|
/// - **1 match** → the full 6-character ID is returned
|
|
/// - **2+ matches** → error: `"ambiguous prefix '{prefix}' matches: {id1}, {id2}, ..."`
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if no match is found, the prefix is ambiguous, or the
|
|
/// tickets directory cannot be read.
|
|
pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result<String> {
|
|
let dir = tickets_dir(root);
|
|
|
|
// Fast path: if exactly 6 chars, check all known extensions.
|
|
if id_or_prefix.len() == 6 {
|
|
for ext in KNOWN_EXTENSIONS {
|
|
if dir.join(format!("{id_or_prefix}.{ext}")).is_file() {
|
|
return Ok(id_or_prefix.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scan directory for prefix matches across all known formats.
|
|
if !dir.is_dir() {
|
|
return Err(format!("no ticket found matching '{id_or_prefix}'").into());
|
|
}
|
|
|
|
let mut entries = fs::read_dir(&dir).await?;
|
|
let mut matches: Vec<String> = Vec::new();
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
// Construct a std::path::PathBuf so it's compatible with Path helpers.
|
|
let path: PathBuf = dir.join(entry.file_name());
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or_default();
|
|
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
continue;
|
|
}
|
|
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
|
|
if stem.starts_with(id_or_prefix) && !matches.contains(&stem.to_string()) {
|
|
matches.push(stem.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
match matches.len() {
|
|
0 => Err(format!("no ticket found matching '{id_or_prefix}'").into()),
|
|
1 => Ok(matches.remove(0)),
|
|
_ => {
|
|
matches.sort();
|
|
Err(format!(
|
|
"ambiguous prefix '{id_or_prefix}' matches: {}",
|
|
matches.join(", ")
|
|
)
|
|
.into())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read and deserialise the ticket with the given `id` from disk.
|
|
///
|
|
/// Extensions are tried in the order defined by [`KNOWN_EXTENSIONS`]. The
|
|
/// first matching file is used. The `id` is not stored inside the file; it
|
|
/// is injected from the `id` parameter after deserialisation, making the
|
|
/// filename stem the authoritative source of truth.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a descriptive error message if no file is found for `id`.
|
|
/// Propagates any other I/O or deserialisation error unchanged.
|
|
pub async fn read_ticket(root: &Path, id: &str) -> Result<Ticket> {
|
|
let dir = tickets_dir(root);
|
|
for ext in KNOWN_EXTENSIONS {
|
|
let path = dir.join(format!("{id}.{ext}"));
|
|
match fs::read(&path).await {
|
|
Ok(bytes) => {
|
|
let format = detect_format(&path);
|
|
let mut ticket = deserialize_by_format(&bytes, format)?;
|
|
ticket.id = id.to_string();
|
|
return Ok(ticket);
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
Err(format!("ticket '{id}' not found").into())
|
|
}
|
|
|
|
/// Report produced by [`migrate_tickets`].
|
|
///
|
|
/// Summarises how many ticket files were updated, were already current,
|
|
/// were skipped by the filter, or could not be parsed.
|
|
#[derive(Debug)]
|
|
pub struct MigrateReport {
|
|
/// Number of files that were re-serialised (had stale content).
|
|
pub updated: usize,
|
|
/// Number of files that were already in the current schema format.
|
|
pub already_current: usize,
|
|
/// Number of files excluded by the caller-supplied [`TicketFilter`].
|
|
pub skipped: usize,
|
|
/// Files that could not be deserialised. Each entry is `(filename, error)`.
|
|
pub errors: Vec<(String, String)>,
|
|
}
|
|
|
|
/// Re-serialise every ticket file through the current serde schema.
|
|
///
|
|
/// For each ticket file in `.nbd/tickets/` (any supported format):
|
|
/// - Deserialise into [`Ticket`], injecting the `id` from the filename stem.
|
|
/// - If the ticket does not match `filter`, count it as skipped and continue.
|
|
/// - Re-serialise in the same format.
|
|
/// - If the bytes differ, write the new content (unless `dry_run` is `true`).
|
|
/// - If the bytes are identical, count the file as already current.
|
|
/// - If deserialisation fails, record the error and leave the file untouched.
|
|
///
|
|
/// When `dry_run` is `true`, no files are written; the report describes what
|
|
/// *would* have changed. The function always returns `Ok` — individual file
|
|
/// errors are collected in [`MigrateReport::errors`] rather than aborting early.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error only if the tickets directory itself cannot be read.
|
|
pub async fn migrate_tickets(
|
|
root: &Path,
|
|
dry_run: bool,
|
|
filter: &TicketFilter,
|
|
) -> Result<MigrateReport> {
|
|
let dir = tickets_dir(root);
|
|
let mut report = MigrateReport {
|
|
updated: 0,
|
|
already_current: 0,
|
|
skipped: 0,
|
|
errors: Vec::new(),
|
|
};
|
|
|
|
if !dir.is_dir() {
|
|
return Ok(report);
|
|
}
|
|
|
|
let mut entries = fs::read_dir(&dir).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
// Construct a std::path::PathBuf so it's compatible with Path helpers.
|
|
let path: PathBuf = dir.join(entry.file_name());
|
|
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or_default();
|
|
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
continue;
|
|
}
|
|
|
|
let filename = path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let stem = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
let raw = match fs::read(&path).await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
report.errors.push((filename, e.to_string()));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let format = detect_format(&path);
|
|
let mut ticket: Ticket = match deserialize_by_format(&raw, format) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
report.errors.push((filename, e.to_string()));
|
|
continue;
|
|
}
|
|
};
|
|
ticket.id = stem;
|
|
|
|
// Apply caller-supplied filter; skip non-matching tickets.
|
|
if !filter.matches(&ticket) {
|
|
report.skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
// Re-serialise in the same format to normalise the schema.
|
|
let new_bytes: Vec<u8> = match format {
|
|
FileFormat::Json => match serialize_json(&ticket) {
|
|
Ok(s) => s.into_bytes(),
|
|
Err(e) => {
|
|
report.errors.push((filename, e.to_string()));
|
|
continue;
|
|
}
|
|
},
|
|
FileFormat::Markdown => match serialize_markdown(&ticket) {
|
|
Ok(s) => s.into_bytes(),
|
|
Err(e) => {
|
|
report.errors.push((filename, e.to_string()));
|
|
continue;
|
|
}
|
|
},
|
|
FileFormat::Toml => match serialize_toml(&ticket) {
|
|
Ok(s) => s.into_bytes(),
|
|
Err(e) => {
|
|
report.errors.push((filename, e.to_string()));
|
|
continue;
|
|
}
|
|
},
|
|
FileFormat::Jsonb => match serialize_jsonb(&ticket) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
report.errors.push((filename, e.to_string()));
|
|
continue;
|
|
}
|
|
},
|
|
};
|
|
|
|
if raw == new_bytes {
|
|
report.already_current += 1;
|
|
} else if dry_run {
|
|
report.updated += 1;
|
|
} else {
|
|
if let Err(e) = fs::write(&path, &new_bytes).await {
|
|
report.errors.push((filename, e.to_string()));
|
|
continue;
|
|
}
|
|
report.updated += 1;
|
|
}
|
|
}
|
|
|
|
Ok(report)
|
|
}
|
|
|
|
/// Read every ticket file in the tickets directory and return them sorted by
|
|
/// priority descending (highest priority first).
|
|
///
|
|
/// All supported file formats (`.json`, `.md`, `.toml`, `.jsonb`) are scanned.
|
|
/// If the tickets directory does not exist, an empty `Vec` is returned.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the directory listing fails, if any ticket file cannot
|
|
/// be read, or if any ticket's content cannot be deserialised.
|
|
pub async fn list_tickets(root: &Path) -> Result<Vec<Ticket>> {
|
|
let dir = tickets_dir(root);
|
|
|
|
if !dir.is_dir() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut entries = fs::read_dir(&dir).await?;
|
|
let mut tickets = Vec::new();
|
|
// Guard against the same logical ticket appearing in multiple formats.
|
|
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
// Construct a std::path::PathBuf so it's compatible with Path helpers.
|
|
let path: PathBuf = dir.join(entry.file_name());
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or_default();
|
|
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
continue;
|
|
}
|
|
let stem = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.ok_or("ticket filename has no valid stem")?
|
|
.to_string();
|
|
if !seen_ids.insert(stem.clone()) {
|
|
// Same ID already loaded from a different-extension file; skip.
|
|
continue;
|
|
}
|
|
let bytes = fs::read(&path).await?;
|
|
let format = detect_format(&path);
|
|
let mut ticket = deserialize_by_format(&bytes, format)?;
|
|
ticket.id = stem;
|
|
tickets.push(ticket);
|
|
}
|
|
|
|
// Highest priority value first; ties preserve filesystem order.
|
|
tickets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
|
Ok(tickets)
|
|
}
|
|
|
|
// ── Turso (libsql) cache ──────────────────────────────────────────────────────
|
|
|
|
/// Open (or create) the libsql cache database at `.nbd/cache.db`.
|
|
///
|
|
/// Runs the schema migration on every open so the table exists when needed.
|
|
/// The returned [`libsql::Connection`] is ready for queries.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the database cannot be opened or the migration fails.
|
|
async fn open_cache(root: &Path) -> Result<libsql::Connection> {
|
|
let db_path = root.join(".nbd").join("cache.db");
|
|
let db = libsql::Builder::new_local(db_path)
|
|
.build()
|
|
.await
|
|
.map_err(|e| msg_err(format!("libsql open error: {e}")))?;
|
|
let conn = db
|
|
.connect()
|
|
.map_err(|e| msg_err(format!("libsql connect error: {e}")))?;
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS tickets (
|
|
id TEXT PRIMARY KEY,
|
|
json TEXT NOT NULL,
|
|
mtime INTEGER NOT NULL
|
|
)",
|
|
(),
|
|
)
|
|
.await
|
|
.map_err(|e| msg_err(format!("libsql migrate error: {e}")))?;
|
|
Ok(conn)
|
|
}
|
|
|
|
/// Like [`list_tickets`] but uses a libsql cache in `.nbd/cache.db` to skip
|
|
/// re-reading files whose modification time has not changed.
|
|
///
|
|
/// ## Cache strategy
|
|
///
|
|
/// 1. Scan `.nbd/tickets/` for files and their mtimes.
|
|
/// 2. For each file: if the cache row exists and the mtime matches, deserialise
|
|
/// the cached JSON; otherwise read the file, parse it, and upsert the cache.
|
|
/// 3. Delete rows for IDs that no longer exist on disk.
|
|
/// 4. Return tickets sorted by priority descending.
|
|
///
|
|
/// Falls back to [`list_tickets`] if the cache cannot be opened or any cache
|
|
/// operation fails, so the caller always gets a result.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Only returns an error if [`list_tickets`] itself fails (the fallback path).
|
|
pub async fn list_tickets_cached(root: &Path) -> Result<Vec<Ticket>> {
|
|
match list_tickets_cached_inner(root).await {
|
|
Ok(tickets) => Ok(tickets),
|
|
Err(_) => list_tickets(root).await,
|
|
}
|
|
}
|
|
|
|
/// Inner implementation of the cached list; errors cause a fallback to the
|
|
/// uncached path in [`list_tickets_cached`].
|
|
async fn list_tickets_cached_inner(root: &Path) -> Result<Vec<Ticket>> {
|
|
let dir = tickets_dir(root);
|
|
|
|
if !dir.is_dir() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let conn = open_cache(root).await?;
|
|
|
|
// Scan the directory, collecting (id, path, mtime_secs) for each ticket file.
|
|
let mut on_disk: Vec<(String, PathBuf, i64)> = Vec::new();
|
|
let mut read_dir = fs::read_dir(&dir).await?;
|
|
while let Some(entry) = read_dir.next_entry().await? {
|
|
let path: PathBuf = dir.join(entry.file_name());
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or_default();
|
|
if !KNOWN_EXTENSIONS.contains(&ext) {
|
|
continue;
|
|
}
|
|
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
|
Some(s) => s.to_string(),
|
|
None => continue,
|
|
};
|
|
let meta = fs::metadata(&path).await?;
|
|
let mtime = meta
|
|
.modified()
|
|
.ok()
|
|
.and_then(|t| {
|
|
t.duration_since(std::time::UNIX_EPOCH)
|
|
.ok()
|
|
.map(|d| d.as_millis() as i64)
|
|
})
|
|
.unwrap_or(0);
|
|
on_disk.push((stem, path, mtime));
|
|
}
|
|
|
|
// Deduplicate: keep only the first occurrence of each ID (same order as list_tickets).
|
|
let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
on_disk.retain(|(id, _, _)| seen_ids.insert(id.clone()));
|
|
|
|
let disk_ids: std::collections::HashSet<String> =
|
|
on_disk.iter().map(|(id, _, _)| id.clone()).collect();
|
|
|
|
let mut tickets: Vec<Ticket> = Vec::with_capacity(on_disk.len());
|
|
|
|
for (id, path, mtime) in &on_disk {
|
|
// Query the cache for this id.
|
|
let cached_json: Option<String> = {
|
|
let mut rows = conn
|
|
.query(
|
|
"SELECT json FROM tickets WHERE id = ?1 AND mtime = ?2",
|
|
libsql::params![id.as_str(), *mtime],
|
|
)
|
|
.await
|
|
.map_err(|e| msg_err(format!("cache query error: {e}")))?;
|
|
if let Some(row) = rows
|
|
.next()
|
|
.await
|
|
.map_err(|e| msg_err(format!("cache row error: {e}")))?
|
|
{
|
|
Some(
|
|
row.get::<String>(0)
|
|
.map_err(|e| msg_err(format!("cache column error: {e}")))?,
|
|
)
|
|
} else {
|
|
None
|
|
}
|
|
};
|
|
|
|
let mut ticket = if let Some(json) = cached_json {
|
|
serde_json::from_str::<Ticket>(&json)
|
|
.map_err(|e| msg_err(format!("cache deserialise error: {e}")))?
|
|
} else {
|
|
// Cache miss: read file and upsert.
|
|
let bytes = fs::read(path).await?;
|
|
let format = detect_format(path);
|
|
let t = deserialize_by_format(&bytes, format)?;
|
|
// Store normalised JSON in the cache.
|
|
let json = serde_json::to_string(&t)?;
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO tickets (id, json, mtime) VALUES (?1, ?2, ?3)",
|
|
libsql::params![id.as_str(), json.as_str(), *mtime],
|
|
)
|
|
.await
|
|
.map_err(|e| msg_err(format!("cache upsert error: {e}")))?;
|
|
t
|
|
};
|
|
ticket.id = id.clone();
|
|
tickets.push(ticket);
|
|
}
|
|
|
|
// Remove stale rows for IDs no longer on disk.
|
|
// We fetch all cached IDs and delete anything not in `disk_ids`.
|
|
let mut rows = conn
|
|
.query("SELECT id FROM tickets", ())
|
|
.await
|
|
.map_err(|e| msg_err(format!("cache scan error: {e}")))?;
|
|
let mut stale: Vec<String> = Vec::new();
|
|
while let Some(row) = rows
|
|
.next()
|
|
.await
|
|
.map_err(|e| msg_err(format!("cache row error: {e}")))?
|
|
{
|
|
let cached_id: String = row
|
|
.get::<String>(0)
|
|
.map_err(|e| msg_err(format!("cache column error: {e}")))?;
|
|
if !disk_ids.contains(&cached_id) {
|
|
stale.push(cached_id);
|
|
}
|
|
}
|
|
for id in stale {
|
|
conn.execute(
|
|
"DELETE FROM tickets WHERE id = ?1",
|
|
libsql::params![id.as_str()],
|
|
)
|
|
.await
|
|
.map_err(|e| msg_err(format!("cache delete error: {e}")))?;
|
|
}
|
|
|
|
tickets.sort_by(|a, b| b.priority.cmp(&a.priority));
|
|
Ok(tickets)
|
|
}
|