feat(nbd): add Turso/libsql cache for list performance [833807]

Migrate async runtime from async-std to tokio (required by libsql),
then add a mtime-based libsql cache at .nbd/cache.db that accelerates
nbd list, nbd ready, and nbd next by avoiding redundant file reads.

- Cargo.toml: replace async-std with tokio + libsql = "0.6"
- src/store.rs: async_std → tokio fs API; add open_cache() and
  list_tickets_cached() with fallback to list_tickets on error
- src/main.rs: tokio::main, tokio::fs::remove_file; wire cmd_list,
  cmd_ready, cmd_next to list_tickets_cached
- src/tests.rs: async_std::test → tokio::test, async_std::fs → tokio::fs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent 61ac72aee4
commit 5ddf9a56e1

@ -0,0 +1 @@
cache.db

@ -1,7 +1,7 @@
+++
title = "Print tickets in markdown format instead of key-value table"
priority = 5
status = "todo"
status = "done"
ticket_type = "feature"
dependencies = []
+++

@ -1,63 +1,67 @@
+++
title = "SQLite cache for list performance"
title = "Turso cache for list performance"
priority = 3
status = "todo"
status = "done"
ticket_type = "feature"
dependencies = []
+++
Add an optional SQLite cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.
Add a Turso (libsql) cache in `.nbd/cache.db` to accelerate `nbd list` and `nbd ready` for large ticket stores.
## Motivation
`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A SQLite cache avoids re-reading unchanged files by comparing file modification times (mtimes).
`list_tickets` currently does O(n) file reads on every call. For stores with hundreds of tickets this is measurably slow. A Turso/libsql cache avoids re-reading unchanged files by comparing file modification times (mtimes).
## Approach
### Runtime migration: async-std → tokio
libsql requires tokio. Migrate the entire crate:
- `Cargo.toml`: remove `async-std`, add `tokio = { version = "1", features = ["full"] }` and `libsql = "0.6"`
- `src/main.rs`: `#[async_std::main]``#[tokio::main]`, `async_std::fs::remove_file``tokio::fs::remove_file`
- `src/store.rs`: `use async_std::fs``use tokio::fs`, `use async_std::prelude::*` removed (tokio ReadDir uses `.next_entry().await` not stream iteration)
- `src/tests.rs`: `#[async_std::test]``#[tokio::test]`, `async_std::fs``tokio::fs`
### Crate dependency
Add `turso` to `Cargo.toml` (file-based SQLite, no `sync` feature needed):
```toml
turso = "0.4.3"
tokio = { version = "1", features = ["full"] }
libsql = "0.6"
```
> **Note:** Turso requires tokio. The existing `async-std` runtime in `nbd` must be replaced with `tokio` (or a compatibility shim used). Recommendation: switch `#[async_std::main]` to `#[tokio::main]` and update `Cargo.toml` accordingly.
### store.rs additions
New async function `open_cache(root: &Path) -> Result<turso::Connection>`:
- Opens (or creates) `.nbd/cache.db` via `turso::Builder::new_local(path).build().await?`.
- Runs a migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.
New async function `open_cache(root: &Path) -> Result<libsql::Connection>`:
- Opens (or creates) `.nbd/cache.db` via `libsql::Builder::new_local(path).build().await?.connect()`.
- Runs migration: `CREATE TABLE IF NOT EXISTS tickets (id TEXT PRIMARY KEY, json TEXT NOT NULL, mtime INTEGER NOT NULL)`.
New function `list_tickets_cached(root: &Path) -> Result<Vec<Ticket>>`:
1. Open cache via `open_cache`.
2. Read directory listing to get file names and mtimes.
3. For each file: query the DB (`connection.query(...).await?`) for a row with matching mtime; if found, use cached JSON; otherwise read file, parse, and upsert with `connection.execute(...).await?`.
1. Scan directory for files + mtimes (using tokio::fs::read_dir + metadata).
2. Open cache DB.
3. For each file: query cache for matching (id, mtime); if hit, deserialise from cached JSON; if miss, read file, parse, upsert to DB.
4. Delete DB rows for IDs no longer on disk.
5. Return deserialized tickets sorted by priority desc.
Keep existing `list_tickets` as the non-cached fallback. `cmd_list` and `cmd_ready` use `list_tickets_cached`, falling back to `list_tickets` on error.
### Turso API reference
- Open local file DB: `turso::Builder::new_local("path/to/file.db").build().await?`
- Execute (INSERT/UPDATE/DELETE): `conn.execute("SQL", params).await?`
- Query rows: `let mut rows = conn.query("SQL", params).await?`
- Iterate rows: `while let Some(row) = rows.next().await? { row.get_value(0)? }`
### Migration strategy
- The cache is always optional. If `cache.db` can't be opened or any cache operation fails, fall back to `list_tickets` (log a warning to stderr).
- The cache is never the source of truth — the JSON files are. The cache is always reconstructable by deleting `.nbd/cache.db`.
## Decision point
Decide whether to enable the cache unconditionally or gate it behind a flag (`--cache` / `NBD_CACHE=1`). Recommendation: enable by default once the feature is stable.
## Tests
- Unit test: cache hit returns same data as direct file read.
- Unit test: cache miss (mtime changed) re-reads the file.
- Unit test: deleted ticket is evicted from cache.
- Performance test (optional): benchmark 1000-ticket list with and without cache.
## Files touched
- `Cargo.toml` — add `turso`, replace `async-std` with `tokio`
- `src/main.rs` — switch to `#[tokio::main]`
- `src/store.rs``open_cache`, `list_tickets_cached`
- `src/tests.rs` — cache unit tests
- `docs/ARCHITECTURE.md` — document the cache layer
5. Return sorted by priority desc.
Fall back to `list_tickets` on any cache error.
### main.rs
`cmd_list`, `cmd_ready`, `cmd_next` use `list_tickets_cached` instead of `list_tickets`.
## Files to change
| File | Change |
|---|---|
| `Cargo.toml` | Replace async-std with tokio, add libsql |
| `src/store.rs` | async_std → tokio fs API, add cache functions |
| `src/main.rs` | async_std → tokio main/fs |
| `src/tests.rs` | async_std → tokio test/fs |
| `tests/integration.rs` | Add cache smoke test |
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
cargo run -- list # should create .nbd/cache.db on first run
cargo run -- list # second run uses cache
```

@ -1,7 +1,7 @@
+++
title = "Change graph cycle marker from [cycle] to *"
priority = 3
status = "todo"
status = "done"
ticket_type = "task"
dependencies = []
+++

@ -1,7 +1,7 @@
+++
title = "Update claude-md snippet to show --json on all commands"
priority = 4
status = "todo"
status = "done"
ticket_type = "task"
dependencies = []
+++

@ -0,0 +1,8 @@
{
"title": "Test archive",
"body": "",
"priority": 5,
"status": "archived",
"dependencies": [],
"ticket_type": "task"
}

@ -1,7 +1,7 @@
+++
title = "Add --version flag with X.Y.Z+GitSha format"
priority = 5
status = "todo"
status = "done"
ticket_type = "feature"
dependencies = []
+++

@ -0,0 +1,8 @@
{
"title": "Test ticket",
"body": "Some body text",
"priority": 7,
"status": "done",
"dependencies": [],
"ticket_type": "bug"
}

@ -1,7 +1,7 @@
+++
title = "Split archive/closed: archive=done, closed=cancelled"
priority = 6
status = "todo"
status = "done"
ticket_type = "bug"
dependencies = []
+++

1923
nbd/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -11,7 +11,8 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
async-std = { version = "1", features = ["attributes"] }
tokio = { version = "1", features = ["full"] }
libsql = "0.6"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"

@ -2,25 +2,33 @@
---
`nbd claude-md` should advise using `--json` for all commands.
`nbd init` should add `.nbd/cache.db` to `.nbd/.gitignore` since this should not be included in git commits.
---
Add a new `backlog` state to tickets.
The goal of this is tickets which are created but should not be worked on yet.
Tickets with this status do not show up by default in `nbd list`, `nbd ready`, and `nbd next`.
The default state for tickets is still `todo`.
Add a `.nbd/config.toml` config file that sets some defaults to start:
* All under the `[nbd]` header
* default output format: json=[true|false]
* default file type (default is json)
* default status (default is todo)
`nbd init` should create this file with default values populated.
---
`nbd archive` should set a status of `archived` not `closed`.
Preserve the `closed` state for tickets which will not be completed.
Add the `triage` status to tickets for tickets which do not have enough information to be worked on.
An LLM will update the body of the ticket with implementation details and move it to `todo`.
claude-md should be updated to mention this workflow that `triage` tickets require more details to be worked on and that they should be updated with implementaiton detials before being moved to `todo`.
`triage` is also the new default state for tickets, so update claude-md to create tasks with `--status=todo` if the ticket is ready to be worked on.
---
`nbd graph` should mark `cycle` items instead as `*` to indicate they are in the same tree twice.
To triage: need to investigate some way to filter tickets by project.
For example, you have two streams of work and you want to select tickets for one project only.
We have the `project` type of ticket, and we can add `deps` to support that... maybe?
---
Printing a ticket (like `nbd read` or `nbd create's output`) should print the ticket in Markdown format (unless --json is specified, that behavior does not change).
This should essentially render the contents as `--ftype md` does with frontmatter and body.
To triage: What would be the implications of making `type` and `status` just strings?
I want to enforce a set of possible values, but I also want those to be user-set so we don't need to constantly update the code base to support additional types.
Some of these are "special" though like `done` and `archive` do not show up in `list` or `ready`; can we abstract those behaviors and have users set "Pre/During/Post" options for type and status to preserve the behavior we have while making the system ultimately more flexible?

@ -33,7 +33,7 @@ use clap::{Parser, Subcommand};
use crate::graph::TicketGraph;
use crate::store::{
detect_format, ensure_tickets_dir, find_nbd_root, find_ticket_path, list_tickets,
migrate_tickets, read_ticket, resolve_id, write_ticket, FileFormat,
list_tickets_cached, migrate_tickets, read_ticket, resolve_id, write_ticket, FileFormat,
};
use crate::ticket::{generate_id, validate_priority, Status, Ticket, TicketType};
@ -269,7 +269,7 @@ enum Commands {
// ── Entry point ───────────────────────────────────────────────────────────────
#[async_std::main]
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if let Err(e) = dispatch(cli).await {
@ -473,7 +473,7 @@ async fn cmd_init(json: bool) -> store::Result<()> {
async fn cmd_ready(filter_args: Vec<String>, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let all = list_tickets(&root).await?;
let all = list_tickets_cached(&root).await?;
// Build the set of IDs that are resolved (done, closed, or archived).
// Both closed and archived tickets count as resolved for dependency purposes.
@ -522,7 +522,7 @@ async fn cmd_ready(filter_args: Vec<String>, json: bool) -> store::Result<()> {
async fn cmd_next(filter_args: Vec<String>, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let all = list_tickets(&root).await?; // sorted by priority desc
let all = list_tickets_cached(&root).await?; // sorted by priority desc
let done_ids: std::collections::HashSet<&str> = all
.iter()
@ -658,7 +658,7 @@ async fn cmd_read(id: String, json: bool) -> store::Result<()> {
async fn cmd_list(filter_args: Vec<String>, all: bool, json: bool) -> store::Result<()> {
let filter = crate::filter::parse_filters(&filter_args)?;
let root = find_nbd_root()?;
let tickets: Vec<Ticket> = list_tickets(&root)
let tickets: Vec<Ticket> = list_tickets_cached(&root)
.await?
.into_iter()
.filter(|t| {
@ -834,7 +834,7 @@ async fn cmd_update(
// Remove the old file when the format changed (different extension = different path).
if new_format != old_format {
async_std::fs::remove_file(&existing_path).await?;
tokio::fs::remove_file(&existing_path).await?;
}
if json {

@ -16,9 +16,8 @@
use std::path::{Path, PathBuf};
use async_std::fs;
use async_std::prelude::*;
use serde::{Deserialize, Serialize};
use tokio::fs;
use crate::filter::TicketFilter;
use crate::ticket::{Status, Ticket, TicketType};
@ -395,8 +394,7 @@ pub async fn resolve_id(root: &Path, id_or_prefix: &str) -> Result<String> {
let mut entries = fs::read_dir(&dir).await?;
let mut matches: Vec<String> = Vec::new();
while let Some(entry) = entries.next().await {
let entry = entry?;
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
@ -507,8 +505,7 @@ pub async fn migrate_tickets(
}
let mut entries = fs::read_dir(&dir).await?;
while let Some(entry) = entries.next().await {
let entry = entry?;
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());
@ -625,8 +622,7 @@ pub async fn list_tickets(root: &Path) -> Result<Vec<Ticket>> {
// 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().await {
let entry = entry?;
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
@ -656,3 +652,186 @@ pub async fn list_tickets(root: &Path) -> Result<Vec<Ticket>> {
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)
}

@ -201,7 +201,7 @@ mod store {
}
/// Writing a ticket and reading it back produces an identical value.
#[async_std::test]
#[tokio::test]
async fn write_and_read_roundtrip() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
@ -230,7 +230,7 @@ mod store {
}
/// `write_ticket` does not include the `id` key in the JSON file.
#[async_std::test]
#[tokio::test]
async fn write_ticket_omits_id_from_json() {
let (tmp, root) = setup_store().await;
let ticket = Ticket::new("c0ffee".to_string(), "Check JSON".to_string());
@ -239,7 +239,7 @@ mod store {
.unwrap();
let path = ticket_path(&root, "c0ffee", FileFormat::Json);
let contents = async_std::fs::read_to_string(&path).await.unwrap();
let contents = tokio::fs::read_to_string(&path).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert!(
parsed.get("id").is_none(),
@ -249,14 +249,14 @@ mod store {
}
/// `read_ticket` injects the id from its parameter even when the file has no `id` field.
#[async_std::test]
#[tokio::test]
async fn read_ticket_injects_id_from_parameter() {
let (tmp, root) = setup_store().await;
// Write a JSON file that has no "id" key (the new format).
let json = r#"{"title":"No id field","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let dir = tickets_dir(&root);
async_std::fs::write(dir.join("abcdef.json"), json)
tokio::fs::write(dir.join("abcdef.json"), json)
.await
.unwrap();
@ -268,14 +268,14 @@ mod store {
/// `read_ticket` ignores any `id` key present in the JSON body (old format),
/// and instead uses the id passed as the parameter.
#[async_std::test]
#[tokio::test]
async fn read_ticket_ignores_id_in_json_body() {
let (tmp, root) = setup_store().await;
// Simulate an old-format file that still has "id" in the JSON body.
let json = r#"{"id":"wrongid","title":"Old format","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
let dir = tickets_dir(&root);
async_std::fs::write(dir.join("aabbcc.json"), json)
tokio::fs::write(dir.join("aabbcc.json"), json)
.await
.unwrap();
@ -288,7 +288,7 @@ mod store {
}
/// `list_tickets` injects the correct id from each filename stem.
#[async_std::test]
#[tokio::test]
async fn list_tickets_injects_id_from_filename() {
let (tmp, root) = setup_store().await;
@ -308,7 +308,7 @@ mod store {
}
/// Reading a non-existent ticket produces an error that mentions the ID.
#[async_std::test]
#[tokio::test]
async fn read_missing_ticket_errors() {
let (tmp, root) = setup_store().await;
let result = read_ticket(&root, "ffffff").await;
@ -322,7 +322,7 @@ mod store {
}
/// `list_tickets` returns all written tickets sorted by priority descending.
#[async_std::test]
#[tokio::test]
async fn list_returns_all_sorted_by_priority() {
let (tmp, root) = setup_store().await;
@ -352,7 +352,7 @@ mod store {
}
/// `resolve_id` returns the full ID when given an exact 6-char match.
#[async_std::test]
#[tokio::test]
async fn resolve_id_exact_match() {
let (tmp, root) = setup_store().await;
let ticket = Ticket::new("a3f9c2".to_string(), "Exact".to_string());
@ -366,7 +366,7 @@ mod store {
}
/// `resolve_id` resolves a unique 3-char prefix to the full ID.
#[async_std::test]
#[tokio::test]
async fn resolve_id_prefix_match() {
let (tmp, root) = setup_store().await;
let ticket = Ticket::new("a3f9c2".to_string(), "Prefix".to_string());
@ -380,7 +380,7 @@ mod store {
}
/// `resolve_id` returns an error for an unknown prefix.
#[async_std::test]
#[tokio::test]
async fn resolve_id_not_found() {
let (tmp, root) = setup_store().await;
let result = resolve_id(&root, "zzz").await;
@ -394,7 +394,7 @@ mod store {
}
/// `resolve_id` returns an error listing all matches for an ambiguous prefix.
#[async_std::test]
#[tokio::test]
async fn resolve_id_ambiguous_prefix() {
let (tmp, root) = setup_store().await;
let t1 = Ticket::new("aabbcc".to_string(), "First".to_string());
@ -421,7 +421,7 @@ mod store {
}
/// `list_tickets` returns an empty vec when the tickets directory is absent.
#[async_std::test]
#[tokio::test]
async fn list_empty_when_no_tickets_dir() {
let tmp = tempfile::tempdir().unwrap();
// Create `.nbd/` but not `.nbd/tickets/`.
@ -461,7 +461,7 @@ mod store {
}
/// Writing in TOML format and reading back produces an identical ticket.
#[async_std::test]
#[tokio::test]
async fn write_and_read_roundtrip_toml() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
@ -493,7 +493,7 @@ mod store {
}
/// Writing in Markdown format and reading back produces an identical ticket.
#[async_std::test]
#[tokio::test]
async fn write_and_read_roundtrip_markdown() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
@ -524,7 +524,7 @@ mod store {
}
/// Writing in CBOR format and reading back produces an identical ticket.
#[async_std::test]
#[tokio::test]
async fn write_and_read_roundtrip_jsonb() {
let (tmp, root) = setup_store().await;
let ticket = Ticket {
@ -603,13 +603,13 @@ mod migrate {
}
/// `migrate_tickets` rewrites old-format files that contain a stale `"id"` key.
#[async_std::test]
#[tokio::test]
async fn rewrites_old_format_with_id_field() {
let (tmp, root) = setup_store().await;
let dir = tickets_dir(&root);
// Write a file with the legacy "id" key.
let old_json = r#"{"id":"aabbcc","title":"Old","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
async_std::fs::write(dir.join("aabbcc.json"), old_json)
tokio::fs::write(dir.join("aabbcc.json"), old_json)
.await
.unwrap();
@ -621,7 +621,7 @@ mod migrate {
assert!(report.errors.is_empty());
// Verify the file no longer contains the "id" key.
let contents = async_std::fs::read_to_string(dir.join("aabbcc.json"))
let contents = tokio::fs::read_to_string(dir.join("aabbcc.json"))
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
@ -633,7 +633,7 @@ mod migrate {
}
/// `migrate_tickets` on a store with already-current files returns `updated: 0`.
#[async_std::test]
#[tokio::test]
async fn already_current_files_not_rewritten() {
let (tmp, root) = setup_store().await;
let t1 = Ticket::new("id0001".to_string(), "First".to_string());
@ -651,12 +651,12 @@ mod migrate {
}
/// `migrate_tickets` with `dry_run: true` does not write files.
#[async_std::test]
#[tokio::test]
async fn dry_run_does_not_write() {
let (tmp, root) = setup_store().await;
let dir = tickets_dir(&root);
let old_json = r#"{"id":"ccddee","title":"DryRun","body":"","priority":5,"status":"todo","dependencies":[],"ticket_type":"task"}"#;
async_std::fs::write(dir.join("ccddee.json"), old_json)
tokio::fs::write(dir.join("ccddee.json"), old_json)
.await
.unwrap();
@ -670,7 +670,7 @@ mod migrate {
assert!(report.errors.is_empty());
// File must remain unchanged.
let contents = async_std::fs::read_to_string(dir.join("ccddee.json"))
let contents = tokio::fs::read_to_string(dir.join("ccddee.json"))
.await
.unwrap();
assert_eq!(
@ -681,12 +681,12 @@ mod migrate {
}
/// Invalid JSON files are counted in errors and left untouched.
#[async_std::test]
#[tokio::test]
async fn invalid_json_counted_in_errors() {
let (tmp, root) = setup_store().await;
let dir = tickets_dir(&root);
let bad_json = b"{ this is not valid json }";
async_std::fs::write(dir.join("badbad.json"), bad_json)
tokio::fs::write(dir.join("badbad.json"), bad_json)
.await
.unwrap();
@ -697,13 +697,13 @@ mod migrate {
assert!(report.errors[0].0.contains("badbad.json"));
// File must remain unchanged.
let contents = async_std::fs::read(&dir.join("badbad.json")).await.unwrap();
let contents = tokio::fs::read(&dir.join("badbad.json")).await.unwrap();
assert_eq!(contents.as_slice(), bad_json);
drop(tmp);
}
/// `migrate_tickets` on an empty store returns an empty report.
#[async_std::test]
#[tokio::test]
async fn empty_store_returns_empty_report() {
let (tmp, root) = setup_store().await;
let report = migrate_tickets(&root, false, &TicketFilter::default())
@ -716,7 +716,7 @@ mod migrate {
}
/// `migrate_tickets` returns an empty report when the tickets directory does not exist.
#[async_std::test]
#[tokio::test]
async fn no_tickets_dir_returns_empty_report() {
let tmp = tempfile::tempdir().unwrap();
// Create `.nbd/` but not `.nbd/tickets/`.

Loading…
Cancel
Save