diff --git a/.gitignore b/.gitignore index 4a805fc..88936b3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ npm/bin/tsq.exe artifacts/ .pi-lens/ context.md +docs/superpowers/ diff --git a/AGENTS-reference.md b/AGENTS-reference.md index 6328b05..1d02b8e 100644 --- a/AGENTS-reference.md +++ b/AGENTS-reference.md @@ -56,7 +56,8 @@ Write path: ## Task Model Task fields: -- `id` (`tsq-<8 crockford base32 chars>` root, `.` child) +- `id` (`tsq-` root, `.` child); legacy `tsq-<8 crockford base32 chars>` IDs remain valid +- `alias` (kebab-case slug generated from the creation title; stable across title edits) - `kind` (`task|feature|epic`) - `title` - `status` (`open|in_progress|blocked|deferred|closed|canceled`) @@ -85,15 +86,21 @@ Relation types: - `tsq` (no args, TTY): open read-only TUI - `tsq init [--wizard|--no-wizard] [--yes] [--preset ] [--sync-branch|--worktree-name ]` - `tsq init --install-skill|--uninstall-skill [--skill-targets ...] [--skill-name ] [--force-skill-overwrite]` -- `tsq create [--kind ...] [-p ...] [--parent ] [--from-file tasks.md] [--description ] [--external-ref ] [--discovered-from ] [--planned|--needs-plan] [--ensure] [--id ] [--body-file ]` +- `tsq create [--kind ...] [-p ...] [--parent ] [--from-file tasks.md] [--description ] [--external-ref ] [--discovered-from ] [--planned|--needs-plan] [--ensure] [--id ] [--body-file ] [--force]` - `tsq show [--with-spec]` - `tsq find ready [--lane ] [--assignee ] [--unassigned] [--kind ...] [--label ...] [--planning ] [--tree [--full]]` - `tsq find [filters...] [--tree [--full]]` - `tsq find search [--full]` - -Note: for `find ready` and status-based `find` commands, `--full` is only valid with `--tree`. `--tree --full` keeps the full status set instead of applying the default tree status narrowing. `find search --full` remains valid without `--tree`. +- `tsq find similar ""` - `tsq watch [--once] [--interval ] [--status ] [--assignee ] [--tree] [--flat]` +Notes: +- For `find ready` and status-based `find` commands, `--full` is only valid with `--tree`. `--tree --full` keeps the full status set instead of applying the default tree status narrowing. `find search --full` remains valid without `--tree`. +- `--id ` accepts `tsq-` or legacy `tsq-<8 crockford base32 chars>`. +- Commands that accept a task ID also accept exact aliases and unique alias prefixes unless `--exact-id` is used. +- `tsq find similar ""` shows ranked duplicate candidates with scores and reasons. +- `tsq create` refuses similar open/in-progress/blocked/deferred tasks unless `--force` is passed. + `watch` renders the task tree by default for human output. Use `--tree` to explicitly request tree view or `--flat` for the compact list view. These options are mutually exclusive. - `tsq tui [--once] [--interval ] [--status ] [--assignee ] [--board|--epics]` - `tsq stale [--days ] [--status ] [--assignee ] [--limit ]` @@ -113,6 +120,8 @@ Note: for `find ready` and status-based `find` commands, `--full` is only valid - `tsq cancel [--note ]` - `tsq orphans` - `tsq spec [--file | --stdin | --text | --show | --check] [--force]` +- `tsq spec --update [--file | --stdin | --text ]` +- `tsq spec --patch [--file | --stdin | --text ]` - `tsq block by ` - `tsq unblock by ` - `tsq order after ` @@ -149,6 +158,7 @@ Planning workflow guidance: - Treat lifecycle `status` and `planning_state` as separate dimensions. - `tsq find ready --lane planning` surfaces tasks that need planning work (`planning_state=needs_planning`). - Planning-lane work should collaborate with the user and update specs/task body as needed before coding. +- Use `tsq spec --update` for full spec replacement and `tsq spec --patch` for small agent edits. Prefer patch input via stdin/file so unified-diff `---` headers are parsed as content. - `tsq find ready --lane coding` surfaces tasks already planned (`planning_state=planned`). - Use `status=deferred` for valid work intentionally parked for later. diff --git a/AGENTS.md b/AGENTS.md index e908f10..03e20a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ Durable across restarts and context compaction. - append-only audit trail - stable machine output (`--json`) - tree find view (`tsq find open --tree`) +- sequential task IDs (`tsq-`) with alias resolution +- ranked search (`find search`) and duplicate detection (`find similar`) +- create duplicate gate (refuses similar tasks unless `--force`) - skill install/uninstall via `tsq init` ## Non-Goals diff --git a/Cargo.lock b/Cargo.lock index 4b8b3cb..196f947 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -274,6 +274,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "diffy" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05264ab2aab4fb952fc4b0f3f6eff1ddfb4563064053a4ea174d91537584a769" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "digest" version = "0.10.7" @@ -345,6 +354,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "generic-array" version = "0.14.7" @@ -398,7 +413,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -407,6 +422,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "heck" version = "0.5.0" @@ -984,6 +1008,7 @@ dependencies = [ "chrono", "clap", "crossterm", + "diffy", "dirs", "once_cell", "rand 0.10.1", diff --git a/Cargo.toml b/Cargo.toml index bae4ba9..9e35697 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,5 +28,6 @@ dirs = "5" once_cell = "1" rust-embed = "8" tempfile = "3" +diffy = "0.5" [dev-dependencies] diff --git a/README.md b/README.md index f5ed6b0..f5ccb86 100644 --- a/README.md +++ b/README.md @@ -52,15 +52,22 @@ Commands: - `tsq` (no args, TTY): open read-only TUI (List/Board views) - `tsq init [--wizard|--no-wizard] [--yes] [--preset ] [--sync-branch|--worktree-name ]` - `tsq init --install-skill|--uninstall-skill [--skill-targets ...] [--skill-name ] [--force-skill-overwrite]` -- `tsq create [--kind ...] [-p ...] [--parent ] [--from-file tasks.md] [--description ] [--external-ref ] [--discovered-from ] [--planned|--needs-plan] [--ensure] [--id ] [--body-file ]` +- `tsq create [--kind ...] [-p ...] [--parent ] [--from-file tasks.md] [--description ] [--external-ref ] [--discovered-from ] [--planned|--needs-plan] [--ensure] [--id ] [--body-file ] [--force]` - `tsq show [--with-spec]` - `tsq find ready [--lane ] [--assignee ] [--unassigned] [--kind ...] [--label ...] [--planning ] [--tree [--full]]` - `tsq find [filters...] [--tree [--full]]` - `tsq find search [--full]` - -Note: for `find ready` and status-based `find` commands, `--full` is only valid with `--tree`. `--tree --full` keeps the full status set instead of applying the default tree status narrowing. `find search --full` remains valid without `--tree`. +- `tsq find similar ""` - `tsq watch [--once] [--interval ] [--status ] [--assignee ] [--tree] [--flat]` +Notes: +- New root task IDs use `tsq-`; legacy `tsq-<8 crockford base32 chars>` IDs remain valid. +- `--id ` accepts `tsq-` or legacy `tsq-<8 crockford base32 chars>`. +- Task JSON includes `alias`, generated from the creation title and stable across title edits. +- Commands that accept a task ID also accept exact aliases and unique alias prefixes unless `--exact-id` is used. +- `tsq find similar ""` shows ranked duplicate candidates. +- `tsq create` refuses similar open/in-progress/blocked/deferred tasks unless `--force` is passed. + `watch` renders the task tree by default for human output. Use `--tree` to explicitly request tree view or `--flat` for the compact list view. These options are mutually exclusive. - `tsq tui [--once] [--interval ] [--status ] [--assignee ] [--board|--epics]` - `tsq stale [--days ] [--status ] [--assignee ] [--limit ]` @@ -80,6 +87,8 @@ Note: for `find ready` and status-based `find` commands, `--full` is only valid - `tsq cancel [--note ]` - `tsq orphans` - `tsq spec [--file | --stdin | --text | --show | --check] [--force]` +- `tsq spec --update [--file | --stdin | --text ]` +- `tsq spec --patch [--file | --stdin | --text ]` - `tsq block by ` - `tsq unblock by ` - `tsq order after ` diff --git a/SKILLS/tasque/SKILL.md b/SKILLS/tasque/SKILL.md index 3e72b1f..a138012 100644 --- a/SKILLS/tasque/SKILL.md +++ b/SKILLS/tasque/SKILL.md @@ -79,11 +79,15 @@ tsq create --parent --from-file tasks.md ```bash tsq spec --text "## Plan\n...\n## Acceptance\n..." tsq spec --show +# Choose one edit mode when changing an existing spec: +tsq spec --update --stdin +tsq spec --patch --stdin tsq planned tsq claim --assignee --start ``` Use `tsq spec --show` when spec markdown lives in sync worktree. +Use `--update` for whole-spec replacement. Use `--patch` for small agent edits; patch applies only to the attached spec and fails if context is stale. Prefer `--stdin` or `--file` for patches because unified diffs start with `---`. ## Parallel Work diff --git a/SKILLS/tasque/references/command-reference.md b/SKILLS/tasque/references/command-reference.md index 03e4ea9..87fba8e 100644 --- a/SKILLS/tasque/references/command-reference.md +++ b/SKILLS/tasque/references/command-reference.md @@ -14,7 +14,7 @@ Use `--sync-branch ` or `--worktree-name ` to choose another branch/ and create the worktree on first use. `tsq sync` pushes the sync branch to `origin` and sets upstream automatically when needed. Non-git directories use local `.tasque/` storage. -- `tsq create [--kind ...] [-p ...] [--parent ] [--from-file tasks.md] [--description ] [--external-ref ] [--discovered-from ] [--planned|--needs-plan] [--ensure] [--id ] [--body-file ]` +- `tsq create [--kind ...] [-p ...] [--parent ] [--from-file tasks.md] [--description ] [--external-ref ] [--discovered-from ] [--planned|--needs-plan] [--ensure] [--id ] [--body-file ] [--force]` `tasks.md` supports nested two-space bullets: @@ -29,8 +29,7 @@ and sets upstream automatically when needed. Non-git directories use local `.tas - `tsq find ready [--lane ] [--assignee ] [--unassigned] [--kind ...] [--label ...] [--planning ] [--tree [--full]]` - `tsq find [filters...] [--tree [--full]]` - `tsq find search [--full]` - -Note: for `find ready` and status-based `find` commands, `--full` is only valid with `--tree`. `--tree --full` keeps the full status set instead of applying the default tree status narrowing. `find search --full` remains valid without `--tree`. +- `tsq find similar ""` - `tsq edit [--title ...] [--description ...] [--clear-description] [--priority ...] [--external-ref ] [--clear-external-ref] [--discovered-from ] [--clear-discovered-from]` - `tsq claim [--assignee ] [--start] [--require-spec]` - `tsq assign --assignee ` @@ -44,6 +43,14 @@ Note: for `find ready` and status-based `find` commands, `--full` is only valid - `tsq reopen [--note ]` - `tsq cancel [--note ]` +Notes: +- New root task IDs use `tsq-`; legacy `tsq-<8 crockford base32 chars>` IDs remain valid. +- `--id ` accepts `tsq-` or legacy `tsq-<8 crockford base32 chars>`. +- Task JSON includes `alias`, generated from the creation title and stable across title edits. +- Commands that accept a task ID also accept exact aliases and unique alias prefixes unless `--exact-id` is used. +- `tsq find similar ""` shows ranked duplicate candidates. +- `tsq create` refuses similar open/in-progress/blocked/deferred tasks unless `--force` is passed. + ## Dependencies and relations - `tsq block by ` @@ -61,6 +68,8 @@ Note: for `find ready` and status-based `find` commands, `--full` is only valid ## Specs, notes, labels, history - `tsq spec [--file | --stdin | --text | --show | --check] [--force]` +- `tsq spec --update [--file | --stdin | --text ]` +- `tsq spec --patch [--file | --stdin | --text ]` - `tsq note ` - `tsq note --stdin` - `tsq notes ` @@ -69,6 +78,11 @@ Note: for `find ready` and status-based `find` commands, `--full` is only valid - `tsq labels` - `tsq history [--limit ] [--type ] [--actor ] [--since ]` +Spec update notes: +- `--update` requires an existing attached spec and atomically replaces the whole spec. +- `--patch` requires an existing attached spec, applies one unified diff to the current spec in memory, rejects multi-file patches, and fails when patch context is stale. +- Prefer `--patch --stdin` or `--patch --file spec.patch`; unified diffs begin with `---`, which can confuse shell/arg parsing when passed via `--text`. + ## Reporting and maintenance - `tsq watch [--once] [--interval ] [--status ] [--assignee ] [--tree] [--flat]` diff --git a/src/app/service.rs b/src/app/service.rs index 92ffe26..782cdbc 100644 --- a/src/app/service.rs +++ b/src/app/service.rs @@ -135,6 +135,13 @@ impl TasqueService { service_create_update::create(&self.ctx, &input) } + pub fn create_batch( + &self, + input: crate::app::service_types::CreateBatchInput, + ) -> Result, TsqError> { + service_create_update::create_batch(&self.ctx, &input) + } + pub fn show(&self, id_raw: &str, exact_id: bool) -> Result { service_query::show(&self.ctx, id_raw, exact_id) } @@ -182,6 +189,14 @@ impl TasqueService { service_specs::spec_attach(&self.ctx, &input) } + pub fn spec_update(&self, input: SpecUpdateInput) -> Result { + service_specs::spec_update(&self.ctx, &input) + } + + pub fn spec_patch(&self, input: SpecPatchInput) -> Result { + service_specs::spec_patch(&self.ctx, &input) + } + pub fn spec_check(&self, input: SpecCheckInput) -> Result { service_specs::spec_check(&self.ctx, &input) } @@ -291,6 +306,13 @@ impl TasqueService { service_query::search(&self.ctx, input) } + pub fn similar( + &self, + input: &crate::app::service_types::SimilarInput, + ) -> Result, TsqError> { + service_query::similar(&self.ctx, input) + } + pub fn migrate(&self, branch: &str) -> Result { crate::app::sync::migrate_to_sync_branch(&self.ctx.repo_root, branch, &self.ctx.actor) } diff --git a/src/app/service_create_update.rs b/src/app/service_create_update.rs index f30fac5..e4f1bfd 100644 --- a/src/app/service_create_update.rs +++ b/src/app/service_create_update.rs @@ -1,21 +1,22 @@ -use crate::app::service_types::{CreateInput, ServiceContext, UpdateInput}; -use crate::app::service_utils::{must_resolve_existing, must_task, unique_root_id}; +use crate::app::service_types::{CreateBatchInput, CreateInput, ServiceContext, UpdateInput}; +use crate::app::service_utils::{ + must_resolve_existing, must_task, normalize_duplicate_title, unique_root_id, +}; use crate::app::storage::{ append_events, load_projected_state, persist_projection, with_write_lock, }; +use crate::domain::alias::allocate_alias; use crate::domain::events::make_event; -use crate::domain::ids::next_child_id; +use crate::domain::ids::{RootIdAllocator, is_valid_root_id, next_child_id}; use crate::domain::projector::apply_events; +use crate::domain::similarity::{ + DEFAULT_SIMILARITY_LIMIT, DEFAULT_SIMILARITY_MIN_SCORE, blocking_status, + find_similar_candidates, is_blocking_duplicate, is_blocking_title_pair, +}; use crate::errors::TsqError; use crate::types::{EventRecord, EventType, PlanningState, State, Task, TaskStatus}; -use once_cell::sync::Lazy; -use regex::Regex; use serde_json::{Map, Value}; -static EXPLICIT_ID_PATTERN: Lazy = Lazy::new(|| { - Regex::new(r"^tsq-[0-9a-hjkmnp-tv-z]{8}$").expect("explicit id regex must compile") -}); - pub fn create(ctx: &ServiceContext, input: &CreateInput) -> Result { if input.explicit_id.is_some() && input.parent.is_some() { return Err(TsqError::new( @@ -31,6 +32,13 @@ pub fn create(ctx: &ServiceContext, input: &CreateInput) -> Result Result", + "explicit --id must match tsq- or legacy tsq-<8 crockford base32 chars>", 1, )); } @@ -57,6 +65,13 @@ pub fn create(ctx: &ServiceContext, input: &CreateInput) -> Result Result Result Map { value.as_object().cloned().unwrap_or_default() } +fn duplicate_candidate_json(c: &crate::domain::similarity::SimilarTaskCandidate) -> Value { + serde_json::json!({ + "id": c.task.id, + "alias": c.task.alias, + "title": c.task.title, + "status": c.task.status, + "score": c.score, + "reason": c.reason, + }) +} + +fn check_duplicate_gate( + state: &State, + title: &str, + force: bool, + skip_duplicate_check: bool, +) -> Result<(), TsqError> { + if force || skip_duplicate_check { + return Ok(()); + } + + let candidates: Vec<_> = state + .tasks + .values() + .filter(|task| blocking_status(task.status)) + .filter_map(|task| is_blocking_duplicate(title, task)) + .collect(); + if candidates.is_empty() { + return Ok(()); + } + + let details_candidates: Vec = candidates.iter().map(duplicate_candidate_json).collect(); + Err(TsqError::new( + "DUPLICATE_TASK_CANDIDATE", + "similar task already exists; use --force to create anyway", + 1, + ) + .with_details(serde_json::json!({ + "input_title": title, + "candidates": details_candidates + }))) +} + +/// Atomic batch create: acquires one write lock, validates all items +/// (incoming pairwise + existing duplicate checks with parent-aware ensure +/// logic), then appends all events together. +/// +/// Returns the created (or ensure-reused) tasks in input order. +pub fn create_batch(ctx: &ServiceContext, input: &CreateBatchInput) -> Result, TsqError> { + if input.ensure && input.force { + return Err(TsqError::new( + "VALIDATION_ERROR", + "cannot combine --ensure with --force", + 1, + )); + } + + with_write_lock(&ctx.repo_root, || { + let loaded = load_projected_state(&ctx.repo_root)?; + + // Resolve CLI --parent once. + let cli_parent_id: Option = input + .parent + .as_ref() + .map(|raw| must_resolve_existing(&loaded.state, raw, input.exact_id)) + .transpose()?; + + // Phase 1: Resolve planned parents and determine which items are + // ensure-reusable vs new. For from-file, parent depends on depth + // stacking; for positional, all share cli_parent_id. + let planned = resolve_batch_plan(&loaded.state, input, cli_parent_id.as_deref())?; + + // Phase 2: Incoming pairwise duplicate check. + if !input.force { + check_incoming_duplicates(&planned, input.ensure)?; + } + + // Phase 3: Existing-task duplicate check. + if !input.force { + check_existing_duplicates(&loaded.state, &planned, input.ensure)?; + } + + // Phase 4: Generate events for all new items. + // For from-file batches, maintain a depth→ID stack to resolve parents + // of items whose parent was BatchNew at planning time. + let mut events: Vec = Vec::new(); + let mut result_tasks: Vec = Vec::with_capacity(planned.len()); + let mut working_state = loaded.state.clone(); + let mut parent_stack: Vec = Vec::new(); // depth → created/reused ID + let mut root_id_allocator: Option = None; + + // Track tasks created in this batch by (normalized_title, parent_id) + // so ensure can reuse earlier batch-created tasks for exact duplicates. + let mut batch_created: std::collections::HashMap<(String, Option), Task> = + std::collections::HashMap::new(); + + let discovered_from = input + .discovered_from + .as_ref() + .map(|raw| must_resolve_existing(&loaded.state, raw, input.exact_id)) + .transpose()?; + + for item in &planned { + let depth = item.depth(); + + match item { + PlannedItem::Reuse(task, _) => { + parent_stack.truncate(depth); + parent_stack.push(task.id.clone()); + result_tasks.push(task.as_ref().clone()); + } + PlannedItem::New { + title, + parent_id: planned_parent_id, + .. + } => { + // Resolve parent: use planned_parent_id if known, otherwise + // look up from parent_stack (for BatchNew parents). + let parent_id = if planned_parent_id.is_some() { + planned_parent_id.clone() + } else if input.from_file && depth > 0 { + parent_stack.get(depth - 1).cloned() + } else { + None + }; + + // Ensure dedup: reuse earlier batch-created task with same + // normalized title and parent instead of creating a duplicate. + if input.ensure { + let key = (normalize_duplicate_title(title), parent_id.clone()); + if let Some(existing) = batch_created.get(&key) { + parent_stack.truncate(depth); + parent_stack.push(existing.id.clone()); + result_tasks.push(existing.clone()); + continue; + } + } + + let id = if let Some(parent) = parent_id.as_ref() { + next_child_id(&working_state, parent) + } else { + if root_id_allocator.is_none() { + root_id_allocator = Some(RootIdAllocator::new(&loaded.state)?); + } + root_id_allocator + .as_mut() + .expect("root id allocator initialized") + .next_id()? + }; + + let description = if input.body_file.is_some() { + input.body_file.clone() + } else { + input.description.clone() + }; + let ts = ctx.now.as_ref()(); + let alias = allocate_alias(&working_state, title)?; + + let duplicate_candidates = if input.force { + let candidates = find_similar_candidates( + working_state.tasks.values(), + title, + DEFAULT_SIMILARITY_MIN_SCORE, + DEFAULT_SIMILARITY_LIMIT, + ); + if candidates.is_empty() { + None + } else { + Some(Value::Array( + candidates.iter().map(duplicate_candidate_json).collect(), + )) + } + } else { + None + }; + + let mut payload = serde_json::json!({ + "id": id, + "title": title, + "alias": alias, + "description": description, + "external_ref": input.external_ref, + "discovered_from": discovered_from, + "kind": input.kind, + "priority": input.priority, + "status": TaskStatus::Open, + "parent_id": parent_id, + "planning_state": input.planning_state.unwrap_or(PlanningState::NeedsPlanning), + }); + if let Some(candidates) = duplicate_candidates { + payload["duplicate_candidates"] = candidates; + } + + let event = make_event( + &ctx.actor, + &ts, + EventType::TaskCreated, + &id, + payload_map(payload), + ); + + working_state = apply_events(&working_state, std::slice::from_ref(&event))?; + events.push(event); + let task = must_task(&working_state, &id)?; + if input.ensure { + let key = (normalize_duplicate_title(title), parent_id.clone()); + batch_created.insert(key, task.clone()); + } + parent_stack.truncate(depth); + parent_stack.push(task.id.clone()); + result_tasks.push(task); + } + } + } + + // Phase 5: Persist all events atomically. + if !events.is_empty() { + append_events(&ctx.repo_root, &events)?; + persist_projection( + &ctx.repo_root, + &mut working_state, + loaded.event_count + events.len(), + None, + )?; + } + + // In ensure mode, result_tasks may contain the same task.id multiple + // times; retain one copy so idempotent duplicate input yields unique output. + if input.ensure { + let mut seen = std::collections::HashSet::new(); + result_tasks.retain(|task| seen.insert(task.id.clone())); + } + + Ok(result_tasks) + }) +} + +/// Planned parent identity for a batch item. +#[derive(Clone, Debug)] +enum PlannedParentId { + /// Resolved to an existing parent (or root if None). + Known(Option), + /// Parent is a new task created earlier in this batch. + /// Cannot be resolved to an existing task. + BatchNew, +} + +/// A resolved batch item: either an existing task to reuse (ensure) or a new task. +#[derive(Clone, Debug)] +enum PlannedItem { + Reuse(Box, usize), + New { + title: String, + parent_id: Option, + planned_parent: PlannedParentId, + marker: Option, + depth: usize, + }, +} + +impl PlannedItem { + fn title(&self) -> &str { + match self { + PlannedItem::Reuse(task, _) => &task.title, + PlannedItem::New { title, .. } => title, + } + } + + fn marker(&self) -> Option { + match self { + PlannedItem::Reuse(..) => None, + PlannedItem::New { marker, .. } => *marker, + } + } + + fn planned_parent(&self) -> PlannedParentId { + match self { + PlannedItem::Reuse(task, _) => PlannedParentId::Known(task.parent_id.clone()), + PlannedItem::New { planned_parent, .. } => planned_parent.clone(), + } + } + + fn depth(&self) -> usize { + match self { + PlannedItem::Reuse(_, depth) => *depth, + PlannedItem::New { depth, .. } => *depth, + } + } + + fn is_reuse(&self) -> bool { + matches!(self, PlannedItem::Reuse(..)) + } +} + +/// Build the batch plan: for each input item, determine if it's an ensure-reuse +/// or a new task, and resolve its parent identity. +fn resolve_batch_plan( + state: &State, + input: &CreateBatchInput, + cli_parent_id: Option<&str>, +) -> Result, TsqError> { + let mut planned: Vec = Vec::with_capacity(input.items.len()); + + if input.from_file { + // From-file: depth-based parent stacking. + // identity_stack[depth] = Some(id) when item at that depth resolved to + // an existing task (ensure reuse or known parent) + // = None when the item is brand new + let mut identity_stack: Vec> = Vec::new(); + + for item in input.items.iter() { + let (parent_id, planned_parent) = if item.depth == 0 { + ( + cli_parent_id.map(|s| s.to_string()), + PlannedParentId::Known(cli_parent_id.map(|s| s.to_string())), + ) + } else { + let parent_depth = item.depth - 1; + if parent_depth >= identity_stack.len() { + return Err(TsqError::new( + "VALIDATION_ERROR", + format!( + "line {} has no parent at depth {}", + item.marker.unwrap_or(0), + parent_depth + ), + 1, + )); + } + match &identity_stack[parent_depth] { + Some(id) => (Some(id.clone()), PlannedParentId::Known(Some(id.clone()))), + None => { + // Parent is a new task in this batch. + (None, PlannedParentId::BatchNew) + } + } + }; + + // Try ensure-reuse if parent is known. + let reused = if input.ensure { + match &planned_parent { + PlannedParentId::Known(pid) => { + find_existing_by_parent_and_title(state, pid.as_deref(), &item.title) + } + PlannedParentId::BatchNew => None, + } + } else { + None + }; + + let identity: Option = reused.as_ref().map(|t| t.id.clone()); + + if let Some(task) = reused { + planned.push(PlannedItem::Reuse(Box::new(task), item.depth)); + } else { + planned.push(PlannedItem::New { + title: item.title.clone(), + parent_id, + planned_parent, + marker: item.marker, + depth: item.depth, + }); + } + + identity_stack.truncate(item.depth); + identity_stack.push(identity); + } + } else { + // Positional titles: all share cli_parent_id. + let pp = PlannedParentId::Known(cli_parent_id.map(|s| s.to_string())); + for item in &input.items { + let reused = if input.ensure { + find_existing_by_parent_and_title(state, cli_parent_id, &item.title) + } else { + None + }; + if let Some(task) = reused { + planned.push(PlannedItem::Reuse(Box::new(task), 0)); + } else { + planned.push(PlannedItem::New { + title: item.title.clone(), + parent_id: cli_parent_id.map(|s| s.to_string()), + planned_parent: pp.clone(), + marker: item.marker, + depth: 0, + }); + } + } + } + + Ok(planned) +} + +/// Check incoming items against each other for similarity. +/// With --ensure, exact-title pairs are always safe: same-parent pairs will be +/// deduplicated by ensure, and different-parent pairs are legitimately distinct. +fn check_incoming_duplicates(planned: &[PlannedItem], ensure: bool) -> Result<(), TsqError> { + for (i, item_a) in planned.iter().enumerate() { + if item_a.is_reuse() { + continue; + } + for item_b in planned.iter().skip(i + 1) { + if item_b.is_reuse() { + continue; + } + if let Some((score, reason)) = is_blocking_title_pair(item_a.title(), item_b.title()) { + // With --ensure, exact normalized matches are safe: ensure + // reuses the first occurrence for same-parent, and different + // parents are legitimately distinct tasks. + if ensure && reason == "normalized_title_exact" { + continue; + } + return Err(TsqError::new( + "DUPLICATE_TASK_CANDIDATE", + "duplicate task title in create input; use --force to create anyway", + 1, + ) + .with_details(serde_json::json!({ + "input_title": item_b.title(), + "input_marker": item_b.marker(), + "candidates": [{ + "title": item_a.title(), + "marker": item_a.marker(), + "score": score, + "reason": reason, + }] + }))); + } + } + } + Ok(()) +} + +/// Check new items against existing tasks for duplicates. +/// With --ensure, exempt exact-title matches only when the existing task's +/// parent matches the item's planned parent (these would have been caught by +/// ensure-reuse in resolve_batch_plan already, but we need to handle the case +/// where the item's parent is BatchNew — then we cannot match any existing task). +fn check_existing_duplicates( + state: &State, + planned: &[PlannedItem], + ensure: bool, +) -> Result<(), TsqError> { + for item in planned { + if item.is_reuse() { + continue; + } + let title = item.title(); + let candidates: Vec<_> = state + .tasks + .values() + .filter(|task| blocking_status(task.status)) + .filter_map(|task| is_blocking_duplicate(title, task)) + .filter(|candidate| { + if !ensure { + return true; + } + if candidate.reason != "normalized_title_exact" { + return true; + } + // Exact match: only block if parent differs from planned. + match item.planned_parent() { + PlannedParentId::Known(parent_id) => { + candidate.task.parent_id.as_deref() != parent_id.as_deref() + } + PlannedParentId::BatchNew => { + // Parent is new in this batch — no existing task can match. + true + } + } + }) + .collect(); + + if !candidates.is_empty() { + let details_candidates: Vec = + candidates.iter().map(duplicate_candidate_json).collect(); + return Err(TsqError::new( + "DUPLICATE_TASK_CANDIDATE", + "similar task already exists; use --force to create anyway", + 1, + ) + .with_details(serde_json::json!({ + "input_title": title, + "input_marker": item.marker(), + "candidates": details_candidates + }))); + } + } + Ok(()) +} + fn find_existing_by_parent_and_title( state: &State, parent_id: Option<&str>, title: &str, ) -> Option { - let normalized_title = crate::app::service_utils::normalize_duplicate_title(title); + let normalized_title = normalize_duplicate_title(title); let mut matches: Vec<&Task> = state .tasks .values() .filter(|task| task.parent_id.as_deref() == parent_id) - .filter(|task| { - crate::app::service_utils::normalize_duplicate_title(&task.title) == normalized_title - }) + .filter(|task| normalize_duplicate_title(&task.title) == normalized_title) .collect(); matches.sort_by(|left, right| { left.created_at diff --git a/src/app/service_query.rs b/src/app/service_query.rs index 1fa506f..ab089e4 100644 --- a/src/app/service_query.rs +++ b/src/app/service_query.rs @@ -400,11 +400,25 @@ pub fn history(ctx: &ServiceContext, input: &HistoryInput) -> Result Result, TsqError> { let loaded = load_projected_state(&ctx.repo_root)?; let filter = parse_query(&input.query)?; - Ok(sort_tasks(&evaluate_query( + let tasks = evaluate_query( &loaded.state.tasks.values().cloned().collect::>(), &filter, &loaded.state, - ))) + ); + Ok(crate::domain::query::rank_search_results(tasks, &filter)) +} + +pub fn similar( + ctx: &ServiceContext, + input: &crate::app::service_types::SimilarInput, +) -> Result, TsqError> { + let loaded = load_projected_state(&ctx.repo_root)?; + Ok(crate::domain::similarity::find_similar_candidates( + loaded.state.tasks.values(), + &input.query, + crate::domain::similarity::DEFAULT_SIMILARITY_MIN_SCORE, + crate::domain::similarity::DEFAULT_SIMILARITY_LIMIT, + )) } pub fn orphans(ctx: &ServiceContext) -> Result { diff --git a/src/app/service_specs.rs b/src/app/service_specs.rs index 088d3a5..8654ea9 100644 --- a/src/app/service_specs.rs +++ b/src/app/service_specs.rs @@ -1,7 +1,8 @@ use super::TasqueService; use crate::app::service_types::{ ServiceContext, SpecAttachInput, SpecAttachResult, SpecAttachSpec, SpecCheckInput, - SpecCheckResult, SpecContentInput, SpecContentResult, + SpecCheckResult, SpecContentInput, SpecContentResult, SpecPatchInput, SpecUpdateInput, + SpecUpdateResult, SpecUpdateSpec, }; use crate::app::service_utils::{must_resolve_existing, must_task}; use crate::app::storage::{ @@ -12,7 +13,8 @@ use crate::app::storage::{ use crate::domain::events::make_event; use crate::domain::projector::apply_events; use crate::errors::TsqError; -use crate::types::EventType; +use crate::types::{EventRecord, EventType, State, Task}; +use diffy::patch_set::{FileOperation, ParseOptions, PatchKind, PatchSet}; use std::path::PathBuf; impl TasqueService { @@ -33,11 +35,7 @@ pub fn spec_attach( })?; let source_content = read_spec_attach_content(&source)?; if source_content.trim().is_empty() { - return Err(TsqError::new( - "VALIDATION_ERROR", - "spec markdown content must not be empty", - 1, - )); + return Err(empty_spec_error("spec markdown content must not be empty")); } with_write_lock(&ctx.repo_root, || { @@ -70,21 +68,13 @@ pub fn spec_attach( let fingerprint = sha256(&spec_file.content); let attached_at = ctx.now.as_ref()(); let attached_by = ctx.actor.clone(); - - let event = make_event( - &ctx.actor, - &attached_at, - EventType::TaskSpecAttached, + let event = make_spec_attached_event( + ctx, &id, - serde_json::json!({ - "spec_path": spec_file.spec_path, - "spec_fingerprint": fingerprint, - "spec_attached_at": attached_at, - "spec_attached_by": attached_by, - }) - .as_object() - .cloned() - .unwrap_or_default(), + &spec_file.spec_path, + &fingerprint, + &attached_at, + &attached_by, ); let mut next_state = apply_events(&loaded.state, std::slice::from_ref(&event))?; append_events(&ctx.repo_root, &[event])?; @@ -108,6 +98,76 @@ pub fn spec_attach( }) } +pub fn spec_update( + ctx: &ServiceContext, + input: &SpecUpdateInput, +) -> Result { + let source = resolve_spec_attach_source(&crate::app::storage::SpecAttachInput { + file: input.file.clone(), + source: None, + text: input.text.clone(), + stdin: input.stdin, + })?; + let source_content = read_spec_attach_content(&source)?; + if source_content.trim().is_empty() { + return Err(empty_spec_error("spec markdown content must not be empty")); + } + + with_write_lock(&ctx.repo_root, || { + let loaded = load_projected_state(&ctx.repo_root)?; + let id = must_resolve_existing(&loaded.state, &input.id, input.exact_id)?; + let existing = must_task(&loaded.state, &id)?; + let attached = validate_attached_spec_current(&ctx.repo_root, &id, &existing)?; + write_updated_spec( + ctx, + &loaded.state, + loaded.event_count, + &id, + &source_content, + attached.spec_fingerprint, + ) + }) +} + +pub fn spec_patch( + ctx: &ServiceContext, + input: &SpecPatchInput, +) -> Result { + let source = resolve_spec_attach_source(&crate::app::storage::SpecAttachInput { + file: input.file.clone(), + source: None, + text: input.text.clone(), + stdin: input.stdin, + })?; + let patch_content = read_spec_attach_content(&source)?; + if patch_content.trim().is_empty() { + return Err(empty_spec_error("spec patch content must not be empty")); + } + + with_write_lock(&ctx.repo_root, || { + let loaded = load_projected_state(&ctx.repo_root)?; + let id = must_resolve_existing(&loaded.state, &input.id, input.exact_id)?; + let existing = must_task(&loaded.state, &id)?; + let (attached, current_content) = + read_current_attached_spec(&ctx.repo_root, &id, &existing)?; + let updated_content = + apply_spec_patch(¤t_content, &patch_content, &attached.spec_path)?; + if updated_content.trim().is_empty() { + return Err(empty_spec_error( + "patched spec markdown content must not be empty", + )); + } + write_updated_spec( + ctx, + &loaded.state, + loaded.event_count, + &id, + &updated_content, + attached.spec_fingerprint, + ) + }) +} + pub fn spec_check( ctx: &ServiceContext, input: &SpecCheckInput, @@ -125,12 +185,29 @@ pub fn spec_content( let loaded = load_projected_state(&ctx.repo_root)?; let id = must_resolve_existing(&loaded.state, &input.id, input.exact_id)?; let task = must_task(&loaded.state, &id)?; + let attached = require_attached_spec(&task)?; + let content = read_attached_spec_content(&ctx.repo_root, &id, &attached.spec_path)?; + + Ok(SpecContentResult { + task_id: id, + spec_path: attached.spec_path, + spec_fingerprint: attached.spec_fingerprint, + content, + }) +} + +struct AttachedSpec { + spec_path: String, + spec_fingerprint: String, +} + +fn require_attached_spec(task: &Task) -> Result { let spec_path = normalize_optional_input(task.spec_path.as_deref()).ok_or_else(|| { TsqError::new( "VALIDATION_ERROR", format!( "task {} has no attached spec; use `tsq spec {} --file spec.md`", - id, id + task.id, task.id ), 1, ) @@ -139,19 +216,66 @@ pub fn spec_content( normalize_optional_input(task.spec_fingerprint.as_deref()).ok_or_else(|| { TsqError::new( "VALIDATION_ERROR", - format!("task {} has no attached spec fingerprint", id), + format!("task {} has no attached spec fingerprint", task.id), 1, ) })?; - let resolved_path = resolve_spec_path(&ctx.repo_root, &spec_path); - let content = std::fs::read_to_string(&resolved_path).map_err(|error| { + Ok(AttachedSpec { + spec_path, + spec_fingerprint, + }) +} + +fn validate_attached_spec_current( + repo_root: &str, + task_id: &str, + task: &Task, +) -> Result { + let (attached, _) = read_current_attached_spec(repo_root, task_id, task)?; + Ok(attached) +} + +fn read_current_attached_spec( + repo_root: &str, + task_id: &str, + task: &Task, +) -> Result<(AttachedSpec, String), TsqError> { + let attached = require_attached_spec(task)?; + let content = read_attached_spec_content(repo_root, task_id, &attached.spec_path)?; + let actual_fingerprint = sha256(&content); + if actual_fingerprint != attached.spec_fingerprint { + return Err(TsqError::new( + "SPEC_CONFLICT", + format!( + "attached spec for task {} has drifted from the recorded fingerprint", + task_id + ), + 1, + ) + .with_details(serde_json::json!({ + "task_id": task_id, + "spec_path": attached.spec_path, + "expected_fingerprint": attached.spec_fingerprint, + "actual_fingerprint": actual_fingerprint, + }))); + } + Ok((attached, content)) +} + +fn read_attached_spec_content( + repo_root: &str, + task_id: &str, + spec_path: &str, +) -> Result { + let resolved_path = resolve_spec_path(repo_root, spec_path); + std::fs::read_to_string(&resolved_path).map_err(|error| { let (code, exit_code, message) = if error.kind() == std::io::ErrorKind::NotFound { ( "VALIDATION_ERROR", 1, format!( "attached spec file not found for task {}; use `tsq spec {} --file spec.md`", - id, id + task_id, task_id ), ) } else { @@ -162,19 +286,159 @@ pub fn spec_content( ) }; TsqError::new(code, message, exit_code).with_details(serde_json::json!({ - "spec_path": spec_path.clone(), + "spec_path": spec_path, "message": error.to_string(), })) - })?; + }) +} - Ok(SpecContentResult { - task_id: id, - spec_path, - spec_fingerprint, - content, +fn write_updated_spec( + ctx: &ServiceContext, + state: &State, + event_count: usize, + id: &str, + content: &str, + old_fingerprint: String, +) -> Result { + let spec_file = write_task_spec_atomic(&ctx.repo_root, id, content)?; + let new_fingerprint = sha256(&spec_file.content); + let attached_at = ctx.now.as_ref()(); + let attached_by = ctx.actor.clone(); + let event = make_spec_attached_event( + ctx, + id, + &spec_file.spec_path, + &new_fingerprint, + &attached_at, + &attached_by, + ); + let mut next_state = apply_events(state, std::slice::from_ref(&event))?; + append_events(&ctx.repo_root, &[event])?; + persist_projection(&ctx.repo_root, &mut next_state, event_count + 1, None)?; + + Ok(SpecUpdateResult { + task: must_task(&next_state, id)?, + spec: SpecUpdateSpec { + spec_path: spec_file.spec_path, + old_fingerprint, + new_fingerprint, + spec_attached_at: attached_at, + spec_attached_by: attached_by, + bytes: spec_file.content.len(), + }, }) } +fn make_spec_attached_event( + ctx: &ServiceContext, + id: &str, + spec_path: &str, + spec_fingerprint: &str, + spec_attached_at: &str, + spec_attached_by: &str, +) -> EventRecord { + make_event( + &ctx.actor, + spec_attached_at, + EventType::TaskSpecAttached, + id, + serde_json::json!({ + "spec_path": spec_path, + "spec_fingerprint": spec_fingerprint, + "spec_attached_at": spec_attached_at, + "spec_attached_by": spec_attached_by, + }) + .as_object() + .cloned() + .unwrap_or_default(), + ) +} + +fn apply_spec_patch( + current_content: &str, + patch_content: &str, + spec_path: &str, +) -> Result { + let patches = parse_spec_patch_set(patch_content)?; + if patches.len() != 1 { + return Err(TsqError::new( + "VALIDATION_ERROR", + "spec patch must contain exactly one file patch", + 1, + ) + .with_details(serde_json::json!({ "patch_count": patches.len() }))); + } + + let file_patch = patches.into_iter().next().expect("one patch"); + match file_patch.operation() { + FileOperation::Modify { original, modified } => { + validate_patch_path(original.as_ref(), spec_path)?; + validate_patch_path(modified.as_ref(), spec_path)?; + } + _ => { + return Err(TsqError::new( + "VALIDATION_ERROR", + "spec patch must modify the attached spec file", + 1, + )); + } + } + let patch = match file_patch.patch() { + PatchKind::Text(patch) => patch, + PatchKind::Binary(_) => { + return Err(TsqError::new( + "VALIDATION_ERROR", + "spec patch must be a text patch", + 1, + )); + } + }; + diffy::apply(current_content, patch).map_err(|error| { + TsqError::new("SPEC_PATCH_FAILED", "spec patch did not apply cleanly", 1) + .with_details(serde_json::json!({ "message": error.to_string() })) + }) +} + +fn parse_spec_patch_set( + patch_content: &str, +) -> Result>, TsqError> { + let parse = |opts| PatchSet::parse(patch_content, opts).collect::, _>>(); + parse(ParseOptions::unidiff()) + .or_else(|_| parse(ParseOptions::gitdiff())) + .map_err(|error| { + TsqError::new("VALIDATION_ERROR", "failed parsing spec patch", 1) + .with_details(serde_json::json!({"message": error.to_string()})) + }) +} + +fn validate_patch_path(path: &str, spec_path: &str) -> Result<(), TsqError> { + let normalized = normalize_patch_path(path); + if normalized == spec_path || normalized == "spec.md" { + return Ok(()); + } + Err(TsqError::new( + "VALIDATION_ERROR", + "spec patch path must match the attached spec file", + 1, + ) + .with_details(serde_json::json!({ + "patch_path": path, + "spec_path": spec_path, + }))) +} + +fn normalize_patch_path(path: &str) -> String { + let path = path.trim(); + path.strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path) + .to_string() +} + +fn empty_spec_error(message: &str) -> TsqError { + TsqError::new("VALIDATION_ERROR", message, 1) +} + fn resolve_spec_path(repo_root: &str, spec_path: &str) -> PathBuf { let path = PathBuf::from(spec_path); if path.is_absolute() { diff --git a/src/app/service_types.rs b/src/app/service_types.rs index f66a5b7..5d4af24 100644 --- a/src/app/service_types.rs +++ b/src/app/service_types.rs @@ -48,6 +48,8 @@ pub struct CreateInput { pub explicit_id: Option, pub body_file: Option, pub ensure: bool, + pub force: bool, + pub skip_duplicate_check: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -223,6 +225,24 @@ pub struct SpecContentInput { pub exact_id: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpecUpdateInput { + pub id: String, + pub file: Option, + pub text: Option, + pub stdin: bool, + pub exact_id: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpecPatchInput { + pub id: String, + pub file: Option, + pub text: Option, + pub stdin: bool, + pub exact_id: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NoteAddResult { pub task_id: String, @@ -259,11 +279,32 @@ pub struct SpecContentResult { pub content: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpecUpdateResult { + pub task: Task, + pub spec: SpecUpdateSpec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpecUpdateSpec { + pub spec_path: String, + pub old_fingerprint: String, + pub new_fingerprint: String, + pub spec_attached_at: String, + pub spec_attached_by: String, + pub bytes: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchInput { pub query: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimilarInput { + pub query: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListFilter { pub statuses: Option>, @@ -386,6 +427,39 @@ pub struct OrphansResult { pub total: usize, } +/// A single item in a batch create request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateBatchItem { + pub title: String, + /// For `--from-file`: nesting depth (0 = root-level in file). + /// For positional titles: always 0 (parent comes from `parent` field). + pub depth: usize, + /// Source line number or positional index (1-based) for error messages. + pub marker: Option, +} + +/// Input for atomic batch create. All items are validated and written under a +/// single write lock so preflight and persistence cannot race. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateBatchInput { + pub items: Vec, + pub kind: TaskKind, + pub priority: Priority, + pub description: Option, + pub external_ref: Option, + pub discovered_from: Option, + /// CLI-level `--parent` (existing task reference). + pub parent: Option, + pub exact_id: bool, + pub planning_state: Option, + pub body_file: Option, + pub ensure: bool, + pub force: bool, + /// Whether items came from `--from-file` (depth-based parent stacking) + /// vs positional titles (all share `parent`). + pub from_file: bool, +} + #[derive(Clone)] pub struct ServiceContext { pub repo_root: String, diff --git a/src/app/service_utils.rs b/src/app/service_utils.rs index 5a2a52a..c5d66f5 100644 --- a/src/app/service_utils.rs +++ b/src/app/service_utils.rs @@ -5,7 +5,6 @@ use crate::errors::TsqError; use crate::types::{RelationType, State, Task, TaskStatus}; use once_cell::sync::Lazy; use regex::Regex; -use ulid::Ulid; static DUPLICATE_TITLE_NON_ALNUM: Lazy = Lazy::new(|| Regex::new(r"[^a-z0-9]+").expect("valid duplicate title non-alnum regex")); @@ -19,24 +18,8 @@ pub const DEFAULT_STALE_STATUSES: &[TaskStatus] = &[ TaskStatus::Deferred, ]; -pub fn unique_root_id(state: &State, title: &str) -> Result { - let max_attempts = 10; - for idx in 0..max_attempts { - let nonce = if idx == 0 { - None - } else { - Some(Ulid::new().to_string()) - }; - let id = make_root_id(Some(title), nonce.as_deref()); - if !state.tasks.contains_key(&id) { - return Ok(id); - } - } - Err(TsqError::new( - "ID_COLLISION", - "unable to allocate unique task id", - 2, - )) +pub fn unique_root_id(state: &State, _title: &str) -> Result { + make_root_id(state) } pub fn must_task(state: &State, id: &str) -> Result { diff --git a/src/cli/commands/spec.rs b/src/cli/commands/spec.rs index 8bdb02f..076c084 100644 --- a/src/cli/commands/spec.rs +++ b/src/cli/commands/spec.rs @@ -1,6 +1,7 @@ use crate::app::service::TasqueService; use crate::app::service_types::{ - SpecAttachInput, SpecCheckInput, SpecContentInput, SpecContentResult, + SpecAttachInput, SpecCheckInput, SpecContentInput, SpecContentResult, SpecPatchInput, + SpecUpdateInput, SpecUpdateResult, }; use crate::cli::action::{GlobalOpts, run_action}; use crate::cli::parsers::as_optional_string; @@ -36,6 +37,8 @@ pub struct SpecCheckArgs { #[derive(Debug, Args)] #[command(after_help = "Examples: tsq spec tsq-abc12345 --file docs/spec.md + tsq spec tsq-abc12345 --update --stdin + tsq spec tsq-abc12345 --patch --file spec.patch tsq spec tsq-abc12345 --text '# Context\n...' tsq spec tsq-abc12345 --show tsq spec tsq-abc12345 --check")] @@ -50,6 +53,10 @@ pub struct SpecArgs { #[arg(long)] pub force: bool, #[arg(long)] + pub update: bool, + #[arg(long)] + pub patch: bool, + #[arg(long)] pub show: bool, #[arg(long)] pub check: bool, @@ -169,6 +176,42 @@ pub fn execute_spec_verb(service: &TasqueService, args: SpecArgs, opts: GlobalOp Ok(()) }, ), + SpecAction::Update => run_action( + "tsq spec", + opts, + || { + service.spec_update(SpecUpdateInput { + id: args.id.clone(), + file: as_optional_string(args.file.as_deref()), + stdin: args.stdin, + text: args.text.clone(), + exact_id: opts.exact_id, + }) + }, + |data| data.clone(), + |data| { + print_spec_update_result(data); + Ok(()) + }, + ), + SpecAction::Patch => run_action( + "tsq spec", + opts, + || { + service.spec_patch(SpecPatchInput { + id: args.id.clone(), + file: as_optional_string(args.file.as_deref()), + stdin: args.stdin, + text: args.text.clone(), + exact_id: opts.exact_id, + }) + }, + |data| data.clone(), + |data| { + print_spec_update_result(data); + Ok(()) + }, + ), SpecAction::Check => run_action( "tsq spec", opts, @@ -211,6 +254,8 @@ pub fn execute_spec_verb(service: &TasqueService, args: SpecArgs, opts: GlobalOp enum SpecAction { Attach, Show, + Update, + Patch, Check, } @@ -224,17 +269,38 @@ fn classify_spec_action(args: &SpecArgs) -> Result { .filter(|provided| *provided) .count(); let actions = attach_sources + usize::from(args.show) + usize::from(args.check); + if args.update && args.patch { + return Err(TsqError::new( + "VALIDATION_ERROR", + "cannot combine --update with --patch", + 1, + )); + } + if (args.update || args.patch) && attach_sources != 1 { + return Err(TsqError::new( + "VALIDATION_ERROR", + "--update and --patch require exactly one source: --text, --file, or --stdin", + 1, + )); + } + if (args.update || args.patch) && (args.show || args.check) { + return Err(TsqError::new( + "VALIDATION_ERROR", + "--update and --patch cannot be combined with --show or --check", + 1, + )); + } if actions != 1 { return Err(TsqError::new( "VALIDATION_ERROR", - "exactly one spec action is required: --text, --file, --stdin, --show, or --check", + "exactly one spec action is required: --text, --file, --stdin, --update with a source, --patch with a source, --show, or --check", 1, )); } - if args.force && attach_sources == 0 { + if args.force && (attach_sources == 0 || args.update || args.patch) { return Err(TsqError::new( "VALIDATION_ERROR", - "--force requires --text, --file, or --stdin", + "--force only applies to spec attach with --text, --file, or --stdin", 1, )); } @@ -242,6 +308,10 @@ fn classify_spec_action(args: &SpecArgs) -> Result { Ok(SpecAction::Show) } else if args.check { Ok(SpecAction::Check) + } else if args.update { + Ok(SpecAction::Update) + } else if args.patch { + Ok(SpecAction::Patch) } else { Ok(SpecAction::Attach) } @@ -257,6 +327,13 @@ fn spec_content_json(data: &SpecContentResult) -> serde_json::Value { }) } +fn print_spec_update_result(data: &SpecUpdateResult) { + print_task(&data.task); + println!("spec={}", data.spec.spec_path); + println!("spec_sha256_old={}", data.spec.old_fingerprint); + println!("spec_sha256_new={}", data.spec.new_fingerprint); +} + fn spec_diagnostic_code_to_string( code: &crate::app::storage::SpecCheckDiagnosticCode, ) -> &'static str { diff --git a/src/cli/commands/task_create.rs b/src/cli/commands/task_create.rs index 122c605..bc6d494 100644 --- a/src/cli/commands/task_create.rs +++ b/src/cli/commands/task_create.rs @@ -1,5 +1,5 @@ use crate::app::service::TasqueService; -use crate::app::service_types::CreateInput; +use crate::app::service_types::{CreateBatchInput, CreateBatchItem, CreateInput}; use crate::cli::action::{GlobalOpts, run_action}; use crate::cli::parsers::{ as_optional_string, parse_kind, parse_priority_value, validate_explicit_id, @@ -7,7 +7,6 @@ use crate::cli::parsers::{ use crate::cli::render::print_task; use crate::errors::TsqError; use clap::Args; -use std::collections::HashSet; use std::fs; #[derive(Debug, Clone, PartialEq, Eq)] @@ -54,6 +53,8 @@ pub struct CreateArgs { pub body_file: Option, #[arg(long, default_value_t = false)] pub ensure: bool, + #[arg(long, default_value_t = false)] + pub force: bool, } pub fn execute_create(service: &TasqueService, args: CreateArgs, opts: GlobalOpts) -> i32 { @@ -70,6 +71,13 @@ pub fn execute_create(service: &TasqueService, args: CreateArgs, opts: GlobalOpt 1, )); } + if args.ensure && args.force { + return Err(TsqError::new( + "VALIDATION_ERROR", + "cannot combine --ensure with --force", + 1, + )); + } if args.explicit_id.is_some() && args.parent.is_some() { return Err(TsqError::new( "VALIDATION_ERROR", @@ -184,71 +192,66 @@ pub fn execute_create(service: &TasqueService, args: CreateArgs, opts: GlobalOpt let external_ref = as_optional_string(args.external_ref.as_deref()); let discovered_from = as_optional_string(args.discovered_from.as_deref()); - let mut created = Vec::with_capacity(create_count); - if let Some(file_tasks) = parsed_file_tasks { - let mut parent_stack: Vec = Vec::new(); - for item in file_tasks { - let parent = if item.depth == 0 { - args.parent.clone() - } else { - Some(parent_stack.get(item.depth - 1).cloned().ok_or_else(|| { - TsqError::new( - "VALIDATION_ERROR", - format!( - "line {} has no parent at depth {}", - item.line_no, - item.depth - 1 - ), - 1, - ) - })?) - }; - let task = service.create(CreateInput { + // Single create: keep existing service.create path. + if create_count == 1 && parsed_file_tasks.is_none() { + let title = positional_titles.into_iter().next().unwrap(); + let task = service.create(CreateInput { + title, + kind, + priority, + description, + external_ref, + discovered_from, + parent: args.parent.clone(), + exact_id: opts.exact_id, + planning_state, + explicit_id, + body_file, + ensure: args.ensure, + force: args.force, + skip_duplicate_check: false, + })?; + return Ok(vec![task]); + } + + // Batch create: build items and delegate to atomic service API. + let from_file = parsed_file_tasks.is_some(); + let items: Vec = if let Some(file_tasks) = parsed_file_tasks { + file_tasks + .into_iter() + .map(|item| CreateBatchItem { title: item.title, - kind, - priority, - description: description.clone(), - external_ref: external_ref.clone(), - discovered_from: discovered_from.clone(), - parent, - exact_id: opts.exact_id, - planning_state, - explicit_id: None, - body_file: body_file.clone(), - ensure: args.ensure, - })?; - parent_stack.truncate(item.depth); - parent_stack.push(task.id.clone()); - created.push(task); - } + depth: item.depth, + marker: Some(item.line_no), + }) + .collect() } else { - for (index, title) in positional_titles.into_iter().enumerate() { - created.push(service.create(CreateInput { + positional_titles + .into_iter() + .enumerate() + .map(|(index, title)| CreateBatchItem { title, - kind, - priority, - description: description.clone(), - external_ref: external_ref.clone(), - discovered_from: discovered_from.clone(), - parent: args.parent.clone(), - exact_id: opts.exact_id, - planning_state, - explicit_id: if index == 0 { - explicit_id.clone() - } else { - None - }, - body_file: body_file.clone(), - ensure: args.ensure, - })?); - } - } - if args.ensure { - let mut seen = HashSet::new(); - created.retain(|task| seen.insert(task.id.clone())); - } + depth: 0, + marker: Some(index + 1), + }) + .collect() + }; - Ok(created) + service.create_batch(CreateBatchInput { + items, + kind, + priority, + description, + external_ref, + discovered_from, + parent: args.parent.clone(), + exact_id: opts.exact_id, + planning_state, + body_file, + ensure: args.ensure, + force: args.force, + from_file, + }) }, |tasks| { if tasks.len() == 1 { diff --git a/src/cli/commands/task_find.rs b/src/cli/commands/task_find.rs index 796ef13..8559a9a 100644 --- a/src/cli/commands/task_find.rs +++ b/src/cli/commands/task_find.rs @@ -1,5 +1,5 @@ use crate::app::service::TasqueService; -use crate::app::service_types::{ListFilter, SearchInput}; +use crate::app::service_types::{ListFilter, SearchInput, SimilarInput}; use crate::cli::action::{GlobalOpts, run_action}; use crate::cli::parsers::{ListParseInput, apply_tree_defaults, parse_lane, parse_list_filter}; use crate::cli::render::{print_task, print_task_list, print_task_tree}; @@ -28,6 +28,7 @@ pub enum FindCommand { Done(FindListArgs), Canceled(FindListArgs), Search(FindSearchArgs), + Similar(FindSimilarArgs), } #[derive(Debug, Args)] @@ -81,6 +82,11 @@ pub struct FindSearchArgs { pub full: bool, } +#[derive(Debug, Args)] +pub struct FindSimilarArgs { + pub query: String, +} + pub fn execute_find(service: &TasqueService, args: FindArgs, opts: GlobalOpts) -> i32 { match args.command { FindCommand::Ready(args) => execute_find_ready(service, args, opts), @@ -107,6 +113,7 @@ pub fn execute_find(service: &TasqueService, args: FindArgs, opts: GlobalOpts) - execute_find_list(service, args, Some("canceled"), "tsq find canceled", opts) } FindCommand::Search(args) => execute_find_search(service, args, opts), + FindCommand::Similar(args) => execute_find_similar(service, args, opts), } } @@ -234,6 +241,35 @@ pub fn execute_find_search(service: &TasqueService, args: FindSearchArgs, opts: ) } +pub fn execute_find_similar( + service: &TasqueService, + args: FindSimilarArgs, + opts: GlobalOpts, +) -> i32 { + run_action( + "tsq find similar", + opts, + || { + service.similar(&SimilarInput { + query: args.query.clone(), + }) + }, + |candidates| serde_json::json!({ "candidates": candidates }), + |candidates| { + if !candidates.is_empty() { + println!("{:>5} {:25} {:12} TITLE", "SCORE", "REASON", "ID"); + } + for candidate in candidates { + println!( + "{:>5.2} {:25} {:12} {}", + candidate.score, candidate.reason, candidate.task.id, candidate.task.title + ); + } + Ok(()) + }, + ) +} + fn parse_find_list_filter( args: &FindListArgs, status: Option<&str>, diff --git a/src/cli/parsers.rs b/src/cli/parsers.rs index 66df287..bb3af22 100644 --- a/src/cli/parsers.rs +++ b/src/cli/parsers.rs @@ -1,6 +1,7 @@ use crate::app::runtime::{normalize_status, parse_priority}; use crate::app::service_types::{DepDirectionFilter, ListFilter}; use crate::domain::dep_tree::DepDirection; +use crate::domain::ids::is_valid_root_id; use crate::domain::labels::normalize_label; use crate::domain::validate::PlanningLane; use crate::errors::TsqError; @@ -9,8 +10,6 @@ use crate::types::{DependencyType, PlanningState, RelationType, TaskKind, TaskSt use once_cell::sync::Lazy; use regex::Regex; -static EXPLICIT_ID_PATTERN: Lazy = - Lazy::new(|| Regex::new(r"^tsq-[0-9a-hjkmnp-tv-z]{8}$").expect("valid explicit id pattern")); static ISO_PATTERN: Lazy = Lazy::new(|| { Regex::new(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$") .expect("valid iso timestamp pattern") @@ -105,10 +104,10 @@ pub fn parse_init_preset(raw: &str) -> Result { pub fn validate_explicit_id(raw: &str) -> Result { let trimmed = raw.trim(); - if !EXPLICIT_ID_PATTERN.is_match(trimmed) { + if !is_valid_root_id(trimmed) { return Err(TsqError::new( "VALIDATION_ERROR", - "explicit --id must match tsq-<8 crockford base32 chars>", + "explicit --id must match tsq- or legacy tsq-<8 crockford base32 chars>", 1, )); } diff --git a/src/cli/render.rs b/src/cli/render.rs index c444255..c53f5bd 100644 --- a/src/cli/render.rs +++ b/src/cli/render.rs @@ -18,12 +18,13 @@ pub fn print_task_list(tasks: &[Task]) { return; } - let header = ["ID", "P", "KIND", "STATUS", "ASSIGNEE", "TITLE"]; + let header = ["ID", "ALIAS", "P", "KIND", "STATUS", "ASSIGNEE", "TITLE"]; let rows: Vec> = tasks .iter() .map(|task| { vec![ task.id.clone(), + task.alias.clone(), task.priority.to_string(), task_kind_to_string(task.kind).to_string(), status_to_string(task.status).to_string(), @@ -63,7 +64,7 @@ pub fn print_task_list(tasks: &[Task]) { let padded = format!("{:width$}", cell, width = widths[index]); if index == 0 { style::task_id(&padded) - } else if index == 3 { + } else if index == 4 { style::status( &padded, parse_status_label(cell.as_str()).unwrap_or(TaskStatus::Open), @@ -78,7 +79,7 @@ pub fn print_task_list(tasks: &[Task]) { } pub fn print_task(task: &Task) { - println!("{} {}", style::task_id(&task.id), task.title); + println!("{} {} {}", style::task_id(&task.id), task.alias, task.title); println!( "{}={} {}={} {}={}", style::key("kind"), @@ -855,10 +856,11 @@ mod tests { let lines = render_task_tree(&tree, TreeRenderOptions { width: Some(width) }); for line in lines.iter().filter(|line| !line.starts_with("total=")) { + let visible_width = visible_char_count(line); assert!( - line.chars().count() <= width, + visible_width <= width, "line exceeded width {width}: {line:?} ({})", - line.chars().count() + visible_width ); assert!( !line.trim_start().starts_with("[p"), @@ -880,14 +882,33 @@ mod tests { lines.join("\n") ); for line in lines.iter().filter(|line| !line.starts_with("total=")) { + let visible_width = visible_char_count(line); assert!( - line.chars().count() <= width, + visible_width <= width, "line exceeded width {width}: {line:?} ({})", - line.chars().count() + visible_width ); } } + fn visible_char_count(value: &str) -> usize { + let mut count = 0; + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for ansi_ch in chars.by_ref() { + if ansi_ch.is_ascii_alphabetic() { + break; + } + } + continue; + } + count += 1; + } + count + } + fn nested_node(depth: usize) -> TaskTreeNode { let id = format!("tsq-deepnode{depth}"); let child = if depth == 0 { @@ -930,6 +951,7 @@ mod tests { fn task(id: &str, title: &str) -> Task { Task { id: id.to_string(), + alias: crate::domain::alias::base_alias(title), kind: TaskKind::Task, title: title.to_string(), description: None, diff --git a/src/domain/alias.rs b/src/domain/alias.rs new file mode 100644 index 0000000..b7a47da --- /dev/null +++ b/src/domain/alias.rs @@ -0,0 +1,72 @@ +use crate::errors::TsqError; +use crate::types::State; +use std::collections::HashSet; + +const MAX_ALIAS_SUFFIX: usize = 1_000_000; + +pub fn base_alias(title: &str) -> String { + let mut out = String::new(); + let mut last_dash = false; + for ch in title.trim().to_lowercase().chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch); + last_dash = false; + } else if !last_dash { + out.push('-'); + last_dash = true; + } + } + let trimmed = out.trim_matches('-').to_string(); + let alias = if trimmed.is_empty() { + "task".to_string() + } else { + trimmed + }; + // Keep generated aliases out of the internal task-id namespace. + if alias.starts_with("tsq-") { + format!("task-{}", alias) + } else { + alias + } +} + +pub fn normalize_alias(raw: &str) -> String { + base_alias(raw) +} + +pub fn allocate_alias(state: &State, title: &str) -> Result { + let taken = taken_aliases_and_ids(state); + allocate_alias_with_taken(title, &taken) +} + +pub fn is_alias_or_id_taken(state: &State, value: &str) -> bool { + let normalized = value.to_lowercase(); + taken_aliases_and_ids(state).contains(&normalized) +} + +pub fn allocate_alias_with_taken(title: &str, taken: &HashSet) -> Result { + let base = normalize_alias(title); + if !taken.contains(&base) { + return Ok(base); + } + for suffix in 2..=MAX_ALIAS_SUFFIX { + let candidate = format!("{}-{}", base, suffix); + if !taken.contains(&candidate) { + return Ok(candidate); + } + } + Err(TsqError::new( + "ALIAS_COLLISION_LIMIT", + "alias collision limit exceeded", + 2, + )) +} + +fn taken_aliases_and_ids(state: &State) -> HashSet { + let mut taken = HashSet::new(); + for task in state.tasks.values() { + taken.insert(task.id.to_lowercase()); + taken.insert(task.alias.to_lowercase()); + } + taken +} diff --git a/src/domain/ids.rs b/src/domain/ids.rs index 6711a4a..1b094cf 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -1,25 +1,93 @@ +use crate::errors::TsqError; use crate::types::State; +use once_cell::sync::Lazy; +use regex::Regex; +use std::collections::HashSet; -const CROCKFORD: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz"; - -pub fn make_root_id(_title: Option<&str>, _nonce: Option<&str>) -> String { - let bytes: [u8; 5] = rand::random(); - let mut id = String::with_capacity(8); - let mut bits = 0u32; - let mut acc = 0u64; - for byte in bytes { - acc = (acc << 8) | byte as u64; - bits += 8; - while bits >= 5 { - bits -= 5; - let index = ((acc >> bits) & 0x1f) as usize; - id.push(CROCKFORD[index] as char); +static SEQUENTIAL_ROOT_ID: Lazy = + Lazy::new(|| Regex::new(r"^tsq-[1-9][0-9]*$").expect("sequential root id regex")); + +pub fn make_root_id(state: &State) -> Result { + let mut next = next_root_number(state)?; + loop { + let candidate = format!("tsq-{}", next); + if !state.tasks.contains_key(&candidate) { + return Ok(candidate); + } + next = next.checked_add(1).ok_or_else(id_overflow_error)?; + } +} + +pub struct RootIdAllocator { + next: u64, + reserved_ids: HashSet, +} + +impl RootIdAllocator { + pub fn new(state: &State) -> Result { + Ok(Self { + next: next_root_number(state)?, + reserved_ids: state.tasks.keys().cloned().collect(), + }) + } + + pub fn next_id(&mut self) -> Result { + loop { + let candidate = format!("tsq-{}", self.next); + if self.reserved_ids.insert(candidate.clone()) { + if let Some(next) = self.next.checked_add(1) { + self.next = next; + } + return Ok(candidate); + } + self.next = self.next.checked_add(1).ok_or_else(id_overflow_error)?; + } + } +} + +fn next_root_number(state: &State) -> Result { + let mut max_seen = 0u64; + for task in state.tasks.values() { + if task.parent_id.is_none() { + if let Some(number) = sequential_number(&task.id) { + max_seen = max_seen.max(number); + } } } - format!("tsq-{}", id) + max_seen.checked_add(1).ok_or_else(id_overflow_error) +} + +fn id_overflow_error() -> TsqError { + TsqError::new("ID_OVERFLOW", "unable to allocate sequential task id", 2) +} + +pub fn is_valid_root_id(raw: &str) -> bool { + is_sequential_root_id(raw) || is_legacy_random_root_id(raw) +} + +pub fn is_sequential_root_id(raw: &str) -> bool { + SEQUENTIAL_ROOT_ID.is_match(raw) +} + +pub fn is_legacy_random_root_id(raw: &str) -> bool { + let Some(rest) = raw.strip_prefix("tsq-") else { + return false; + }; + rest.len() == 8 + && rest + .chars() + .all(|ch| matches!(ch, '0'..='9' | 'a'..='h' | 'j'..='k' | 'm'..='n' | 'p'..='t' | 'v'..='z')) } pub fn next_child_id(state: &State, parent_id: &str) -> String { let max_child = state.child_counters.get(parent_id).copied().unwrap_or(0); format!("{}.{}", parent_id, max_child + 1) } + +fn sequential_number(raw: &str) -> Option { + if !is_sequential_root_id(raw) { + return None; + } + let suffix = raw.strip_prefix("tsq-")?; + suffix.parse::().ok() +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 10b21da..28301d3 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,3 +1,4 @@ +pub mod alias; pub mod dep_tree; pub mod deps; pub mod event_payload_codecs; @@ -7,6 +8,7 @@ pub mod labels; pub mod projector; pub mod query; pub mod resolve; +pub mod similarity; pub mod state; pub(crate) mod state_invariants; pub mod validate; diff --git a/src/domain/projector_tasks.rs b/src/domain/projector_tasks.rs index 58c8ecb..ea543c9 100644 --- a/src/domain/projector_tasks.rs +++ b/src/domain/projector_tasks.rs @@ -4,6 +4,7 @@ use super::projector_helpers::{ optional_task_kind_field, optional_task_ref_field, optional_task_status_field, require_task, set_child_counter, set_task_closed_state, task_status_to_string, }; +use crate::domain::alias::{allocate_alias, is_alias_or_id_taken, normalize_alias}; use crate::errors::TsqError; use crate::store::paths::is_task_spec_relative_path; use crate::types::{EventRecord, PlanningState, Task, TaskKind, TaskNote, TaskStatus}; @@ -69,10 +70,21 @@ pub(crate) fn apply_task_created( require_task(state, discovered_from)?; } + let title = title.unwrap(); + let alias = match as_string(payload.get("alias")) { + Some(alias) if alias.eq_ignore_ascii_case(&event.task_id) => { + return Err(invalid_created_alias_error(event, alias)); + } + Some(alias) => normalize_alias(&alias), + None => allocate_alias(state, &title)?, + }; + let alias = validate_created_alias(state, event, alias)?; + let task = Task { id: event.task_id.clone(), + alias, kind, - title: title.unwrap(), + title, description: as_string(payload.get("description")), notes: Vec::new(), spec_path: None, @@ -108,6 +120,30 @@ pub(crate) fn apply_task_created( Ok(()) } +fn validate_created_alias( + state: &crate::types::State, + event: &EventRecord, + alias: String, +) -> Result { + if alias.eq_ignore_ascii_case(&event.task_id) || is_alias_or_id_taken(state, &alias) { + return Err(invalid_created_alias_error(event, alias)); + } + Ok(alias) +} + +fn invalid_created_alias_error(event: &EventRecord, alias: String) -> TsqError { + TsqError::new( + "INVALID_EVENT", + "task.created alias conflicts with existing task identity", + 1, + ) + .with_details(serde_json::json!({ + "event_id": event_id_value(event), + "task_id": &event.task_id, + "alias": alias, + })) +} + pub(crate) fn apply_task_updated( state: &mut crate::types::State, event: &EventRecord, diff --git a/src/domain/query.rs b/src/domain/query.rs index 42dc7f2..8c27d4f 100644 --- a/src/domain/query.rs +++ b/src/domain/query.rs @@ -146,7 +146,16 @@ fn matches_all( fn match_term(task: &Task, term: &QueryTerm, state: &State, context: &QueryEvalContext) -> bool { match term.field.as_str() { - "id" => task.id == term.value || task.id.starts_with(&term.value), + "id" => { + let value = term.value.to_lowercase(); + let id = task.id.to_lowercase(); + id == value || id.starts_with(&value) + } + "alias" => { + let value = term.value.to_lowercase(); + let alias = task.alias.to_lowercase(); + alias == value || alias.starts_with(&value) + } "text" => match_task_text(task, &term.value), "title" => task .title @@ -227,21 +236,41 @@ fn has_incoming_dep_type(context: &QueryEvalContext, task_id: &str, raw_type: &s fn match_task_text(task: &Task, value: &str) -> bool { let needle = value.to_lowercase(); - if task.title.to_lowercase().contains(&needle) { + let id = task.id.to_lowercase(); + if id == needle || id.starts_with(&needle) { return true; } if task - .description - .as_deref() - .unwrap_or("") + .alias .to_lowercase() - .contains(&needle) + .contains(&needle.replace(' ', "-")) { return true; } + if text_matches(&task.title, &needle) { + return true; + } + if text_matches(task.description.as_deref().unwrap_or(""), &needle) { + return true; + } + if text_matches(task.external_ref.as_deref().unwrap_or(""), &needle) { + return true; + } + if task.labels.iter().any(|label| text_matches(label, &needle)) { + return true; + } task.notes .iter() - .any(|note| note.text.to_lowercase().contains(&needle)) + .any(|note| text_matches(¬e.text, &needle)) +} + +fn text_matches(haystack: &str, needle: &str) -> bool { + let haystack_lower = haystack.to_lowercase(); + if haystack_lower.contains(needle) { + return true; + } + let tokens: Vec<&str> = needle.split_whitespace().collect(); + tokens.len() > 1 && tokens.iter().all(|token| haystack_lower.contains(token)) } fn tokenize(input: &str) -> Vec { @@ -327,7 +356,8 @@ fn unquote(value: &str) -> String { fn is_supported_field(field: &str) -> bool { matches!( field, - "id" | "text" + "id" | "alias" + | "text" | "title" | "description" | "notes" @@ -344,3 +374,117 @@ fn is_supported_field(field: &str) -> bool { | "dep_type_out" ) } + +pub fn rank_search_results(mut tasks: Vec, filter: &QueryFilter) -> Vec { + let ranking_text = extract_ranking_text(filter); + let deterministic_fallback = |a: &Task, b: &Task| -> std::cmp::Ordering { + a.priority + .cmp(&b.priority) + .then_with(|| a.created_at.cmp(&b.created_at)) + .then_with(|| a.id.cmp(&b.id)) + }; + if ranking_text.is_empty() { + tasks.sort_by(deterministic_fallback); + return tasks; + } + let query_norm = ranking_text.to_lowercase(); + let query_alias = query_norm.replace(' ', "-"); + let query_tokens = query_norm.split_whitespace().collect::>(); + let mut ranked = tasks + .into_iter() + .map(|task| { + let index = SearchIndex::from_task(&task); + let score = search_score(&index, &query_norm, &query_alias, &query_tokens); + (task, score) + }) + .collect::>(); + ranked.sort_by(|(a, a_score), (b, b_score)| { + b_score + .cmp(a_score) + .then_with(|| deterministic_fallback(a, b)) + }); + ranked.into_iter().map(|(task, _)| task).collect() +} + +/// Extract text relevant for ranking from parsed query terms. +/// Includes bare text terms and text-like field terms (title, description, notes, alias). +/// Excludes hard filters (status, kind, priority, id, etc.). +fn extract_ranking_text(filter: &QueryFilter) -> String { + let mut parts: Vec<&str> = Vec::new(); + for term in &filter.terms { + if term.negated { + continue; + } + match term.field.as_str() { + "text" | "title" | "description" | "notes" | "alias" => { + parts.push(&term.value); + } + _ => {} + } + } + parts.join(" ") +} + +struct SearchIndex { + id: String, + alias: String, + title: String, + labels: Vec, + external_ref: String, + description: String, + notes: Vec, +} + +impl SearchIndex { + fn from_task(task: &Task) -> Self { + Self { + id: task.id.to_lowercase(), + alias: task.alias.to_lowercase(), + title: task.title.to_lowercase(), + labels: task + .labels + .iter() + .map(|label| label.to_lowercase()) + .collect(), + external_ref: task.external_ref.as_deref().unwrap_or("").to_lowercase(), + description: task.description.as_deref().unwrap_or("").to_lowercase(), + notes: task + .notes + .iter() + .map(|note| note.text.to_lowercase()) + .collect(), + } + } +} + +fn search_score( + task: &SearchIndex, + query_norm: &str, + query_alias: &str, + query_tokens: &[&str], +) -> u32 { + if task.id == query_norm || task.alias == query_alias { + return 100; + } + if task.id.starts_with(query_norm) || task.alias.starts_with(query_alias) { + return 90; + } + if task.title.contains(query_norm) { + return 80; + } + if !query_tokens.is_empty() && query_tokens.iter().all(|token| task.title.contains(token)) { + return 70; + } + if task.labels.iter().any(|label| label.contains(query_norm)) + || task.external_ref.contains(query_norm) + { + return 60; + } + if task.description.contains(query_norm) { + return 50; + } + if task.notes.iter().any(|note| note.contains(query_norm)) { + return 40; + } + 0 +} diff --git a/src/domain/resolve.rs b/src/domain/resolve.rs index dcb8112..98fdb77 100644 --- a/src/domain/resolve.rs +++ b/src/domain/resolve.rs @@ -7,41 +7,71 @@ pub fn resolve_task_id(state: &State, raw: &str, exact_id: bool) -> Result = state + let raw_alias = raw.to_lowercase(); + if let Some(task) = state .tasks - .keys() - .filter(|task_id| task_id.starts_with(raw)) - .cloned() + .values() + .find(|task| task.alias.to_lowercase() == raw_alias) + { + return Ok(task.id.clone()); + } + + let mut id_matches: Vec<(String, String)> = state + .tasks + .values() + .filter(|task| task.id.starts_with(raw)) + .map(|task| (task.id.clone(), task.alias.clone())) .collect(); - matches.sort(); + id_matches.sort_by(|a, b| a.0.cmp(&b.0)); - if matches.len() == 1 { - return Ok(matches[0].clone()); + match id_matches.len() { + 1 => return Ok(id_matches[0].0.clone()), + n if n > 1 => { + return Err( + TsqError::new("TASK_ID_AMBIGUOUS", "Task ID is ambiguous", 1).with_details(json!({ + "input": raw, + "candidates": id_matches + .into_iter() + .map(|(id, alias)| json!({ "id": id, "alias": alias })) + .collect::>() + })), + ); + } + _ => {} } - if matches.is_empty() { - return Err( - TsqError::new("TASK_NOT_FOUND", "Task ID not found", 1).with_details(json!({ - "input": raw + let mut alias_matches: Vec<(String, String)> = state + .tasks + .values() + .filter(|task| task.alias.to_lowercase().starts_with(&raw_alias)) + .map(|task| (task.id.clone(), task.alias.clone())) + .collect(); + alias_matches.sort_by(|a, b| a.0.cmp(&b.0)); + + match alias_matches.len() { + 0 => Err(not_found(raw)), + 1 => Ok(alias_matches[0].0.clone()), + _ => Err( + TsqError::new("TASK_ID_AMBIGUOUS", "Task ID is ambiguous", 1).with_details(json!({ + "input": raw, + "candidates": alias_matches + .into_iter() + .map(|(id, alias)| json!({ "id": id, "alias": alias })) + .collect::>() })), - ); + ), } +} - Err( - TsqError::new("TASK_ID_AMBIGUOUS", "Task ID is ambiguous", 1).with_details(json!({ - "input": raw, - "candidates": matches - })), - ) +fn not_found(raw: &str) -> TsqError { + TsqError::new("TASK_NOT_FOUND", "Task ID not found", 1).with_details(json!({ + "input": raw + })) } diff --git a/src/domain/similarity.rs b/src/domain/similarity.rs new file mode 100644 index 0000000..b00bec3 --- /dev/null +++ b/src/domain/similarity.rs @@ -0,0 +1,206 @@ +use crate::types::{Task, TaskStatus}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +pub const DEFAULT_SIMILARITY_MIN_SCORE: f64 = 0.35; +pub const DEFAULT_SIMILARITY_LIMIT: usize = 10; +pub const BLOCKING_DUPLICATE_THRESHOLD: f64 = 0.8; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimilarTaskCandidate { + pub task: Task, + pub score: f64, + pub reason: String, +} + +pub fn blocking_status(status: TaskStatus) -> bool { + matches!( + status, + TaskStatus::Open | TaskStatus::InProgress | TaskStatus::Blocked | TaskStatus::Deferred + ) +} + +pub fn find_similar_candidates<'a>( + tasks: impl IntoIterator, + input: &str, + min_score: f64, + limit: usize, +) -> Vec { + let mut candidates = tasks + .into_iter() + .filter_map(|task| score_task(input, task).map(|(score, reason)| (task, score, reason))) + .filter(|(_, score, _)| *score >= min_score) + .map(|(task, score, reason)| SimilarTaskCandidate { + task: task.clone(), + score, + reason, + }) + .collect::>(); + candidates.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.task.priority.cmp(&b.task.priority)) + .then_with(|| a.task.created_at.cmp(&b.task.created_at)) + .then_with(|| a.task.id.cmp(&b.task.id)) + }); + candidates.truncate(limit); + candidates +} + +pub fn is_blocking_duplicate(input: &str, task: &Task) -> Option { + let (score, reason) = score_task(input, task)?; + if score >= BLOCKING_DUPLICATE_THRESHOLD + || reason == "normalized_title_exact" + || reason == "alias_exact" + { + Some(SimilarTaskCandidate { + task: task.clone(), + score, + reason, + }) + } else { + None + } +} + +/// Check if two raw title strings are similar enough to be blocking duplicates. +/// Returns `Some((score, reason))` if they are, `None` otherwise. +pub fn is_blocking_title_pair(a: &str, b: &str) -> Option<(f64, String)> { + let (score, reason) = score_titles(a, b)?; + if score >= BLOCKING_DUPLICATE_THRESHOLD || reason == "normalized_title_exact" { + Some((score, reason)) + } else { + None + } +} + +pub fn normalized_text(input: &str) -> String { + let chars = input.to_lowercase().chars().collect::>(); + chars + .iter() + .enumerate() + .map(|(index, ch)| { + if ch.is_ascii_alphanumeric() || *ch == '#' { + return *ch; + } + if matches!(ch, '.' | '-') { + let prev_is_digit = index + .checked_sub(1) + .and_then(|prev| chars.get(prev)) + .map(|prev| prev.is_ascii_digit()) + .unwrap_or(false); + let next_is_digit = chars + .get(index + 1) + .map(|next| next.is_ascii_digit()) + .unwrap_or(false); + if prev_is_digit || next_is_digit { + return *ch; + } + } + ' ' + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn score_task(input: &str, task: &Task) -> Option<(f64, String)> { + let input_norm = normalized_text(input); + if input_norm.is_empty() { + return None; + } + let title_norm = normalized_text(&task.title); + if input_norm == title_norm { + return Some((1.0, "normalized_title_exact".to_string())); + } + let input_alias = input_norm.replace(' ', "-"); + let task_alias = task.alias.to_lowercase(); + if input_alias == task_alias { + return Some((1.0, "alias_exact".to_string())); + } + if task_alias.starts_with(&input_alias) { + return Some((0.95, "alias_prefix".to_string())); + } + score_normalized_titles(&input_norm, &title_norm) +} + +fn score_titles(a: &str, b: &str) -> Option<(f64, String)> { + let a_norm = normalized_text(a); + if a_norm.is_empty() { + return None; + } + let b_norm = normalized_text(b); + if a_norm == b_norm { + return Some((1.0, "normalized_title_exact".to_string())); + } + score_normalized_titles(&a_norm, &b_norm) +} + +fn score_normalized_titles(input_norm: &str, title_norm: &str) -> Option<(f64, String)> { + // Only treat substring containment as a phrase match when the contained + // string has at least 2 meaningful tokens. This prevents single-char or + // single-word titles (e.g. "A") from matching longer titles (e.g. "Parent") + // via trivial substring hits. + if title_norm.contains(input_norm) || input_norm.contains(title_norm) { + let contained = if title_norm.contains(input_norm) { + input_norm + } else { + title_norm + }; + if meaningful_tokens(contained).len() >= 2 { + return Some((0.9, "title_phrase".to_string())); + } + } + let input_tokens = meaningful_tokens(input_norm); + let title_tokens = meaningful_tokens(title_norm); + let overlap = token_overlap(&input_tokens, &title_tokens)?; + if overlap >= 0.8 { + return Some((overlap, "title_token_containment".to_string())); + } + if overlap >= 0.35 { + return Some((overlap, "title_token_overlap".to_string())); + } + None +} + +fn meaningful_tokens(input: &str) -> HashSet { + const STOPWORDS: &[&str] = &["a", "an", "the", "of", "to", "for", "and", "or", "in", "on"]; + input + .split_whitespace() + .filter(|token| token.len() >= 3 && !STOPWORDS.contains(token)) + .map(stem_plural) + .collect() +} + +/// Normalize simple English plurals so "warning" and "warnings" match. +fn stem_plural(token: &str) -> String { + if token.len() > 3 && token.ends_with('s') && !token.ends_with("ss") { + token[..token.len() - 1].to_string() + } else { + token.to_string() + } +} + +fn token_overlap(left: &HashSet, right: &HashSet) -> Option { + let min_len = left.len().min(right.len()); + if min_len < 2 { + return None; + } + let intersection = left.intersection(right).count(); + Some(intersection as f64 / min_len as f64) +} + +#[cfg(test)] +mod tests { + use super::normalized_text; + + #[test] + fn normalized_text_preserves_issue_and_version_tokens() { + assert_eq!( + normalized_text("Fix #123 in v1.2.3 and GH-456"), + "fix #123 in v1.2.3 and gh-456" + ); + } +} diff --git a/src/types.rs b/src/types.rs index b06f6b1..8b03da7 100644 --- a/src/types.rs +++ b/src/types.rs @@ -4,7 +4,7 @@ use serde_json::Value; use std::collections::HashMap; pub const SCHEMA_VERSION: u32 = 1; -pub const STATE_CACHE_SCHEMA_VERSION: u32 = 2; +pub const STATE_CACHE_SCHEMA_VERSION: u32 = 3; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -61,6 +61,7 @@ pub struct TaskNote { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Task { pub id: String, + pub alias: String, pub kind: TaskKind, pub title: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 81e6973..a2781ac 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -37,12 +37,21 @@ pub fn init_repo(repo: &Path) { } pub fn create_task(repo: &Path, title: &str) -> String { - create_task_with_args(repo, title, &[]) + // Most tests want duplicate detection out of the way; pass --ensure or + // --force into create_task_with_args when a test needs explicit behavior. + create_task_with_args(repo, title, &["--force"]) } pub fn create_task_with_args(repo: &Path, title: &str, extra_args: &[&str]) -> String { let mut args = vec!["create".to_string(), title.to_string()]; args.extend(extra_args.iter().map(|value| (*value).to_string())); + // Auto-force unless caller already selected duplicate behavior. + if !extra_args + .iter() + .any(|value| matches!(*value, "--force" | "--ensure")) + { + args.push("--force".to_string()); + } let result = run_json(repo, args); assert_eq!( result.cli.code, 0, diff --git a/tests/create_children_batch.rs b/tests/create_children_batch.rs index ffd54c1..40350dd 100644 --- a/tests/create_children_batch.rs +++ b/tests/create_children_batch.rs @@ -294,3 +294,63 @@ fn create_rejects_ensure_with_explicit_id() { Some("cannot combine --ensure with --id") ); } + +#[test] +fn ensure_deduplicates_identical_incoming_root_tasks() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let result = run_json( + repo.path(), + ["create", "--ensure", "Root task", "Root task"], + ); + + assert_eq!(result.cli.code, 0); + // Verify only one open task with that title exists. + let listed = run_json(repo.path(), ["find", "open"]); + assert_eq!(listed.cli.code, 0); + let root_matches = listed + .envelope + .get("data") + .and_then(|value| value.get("tasks")) + .and_then(Value::as_array) + .expect("expected data.tasks array") + .iter() + .filter(|task| task.get("title").and_then(Value::as_str) == Some("Root task")) + .count(); + assert_eq!( + root_matches, 1, + "ensure must deduplicate identical incoming root tasks" + ); +} + +#[test] +fn ensure_from_file_deduplicates_identical_children_under_same_parent() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let file = repo.path().join("tasks.md"); + std::fs::write(&file, "- Parent\n - Child task\n - Child task\n").unwrap(); + + let result = run_json( + repo.path(), + ["create", "--from-file", "tasks.md", "--ensure"], + ); + + assert_eq!(result.cli.code, 0); + let listed = run_json(repo.path(), ["find", "open"]); + assert_eq!(listed.cli.code, 0); + let child_matches = listed + .envelope + .get("data") + .and_then(|value| value.get("tasks")) + .and_then(Value::as_array) + .expect("expected data.tasks array") + .iter() + .filter(|task| task.get("title").and_then(Value::as_str) == Some("Child task")) + .count(); + assert_eq!( + child_matches, 1, + "ensure must deduplicate identical children under same parent" + ); +} diff --git a/tests/projector_invariants.rs b/tests/projector_invariants.rs index 164360d..2690017 100644 --- a/tests/projector_invariants.rs +++ b/tests/projector_invariants.rs @@ -2,6 +2,9 @@ use serde_json::{Map, Value, json}; use tasque::app::repair::scan_orphaned_graph; use tasque::app::storage::evaluate_task_spec; use tasque::domain::projector::apply_events; +use tasque::domain::query::{evaluate_query, parse_query}; +use tasque::domain::resolve::resolve_task_id; +use tasque::domain::similarity::is_blocking_duplicate; use tasque::domain::state::create_empty_state; use tasque::types::{EventRecord, EventType, PlanningState, TaskStatus}; @@ -60,6 +63,75 @@ fn task_created_rejects_invalid_optional_typed_fields() { } } +#[test] +fn task_created_rejects_alias_collisions() { + assert_invalid_event(&[ + created( + "tsq-root0001", + json!({"title": "root", "alias": "shared-alias"}), + ), + created( + "tsq-root0002", + json!({"title": "other", "alias": "shared-alias"}), + ), + ]); + + assert_invalid_event(&[created( + "tsq-root0001", + json!({"title": "root", "alias": "tsq-root0001"}), + )]); +} + +#[test] +fn task_created_normalizes_explicit_alias() { + let state = apply_events( + &create_empty_state(), + &[created( + "tsq-root0001", + json!({"title": "root", "alias": "Mixed Alias!"}), + )], + ) + .expect("create should apply"); + + let task = state.tasks.get("tsq-root0001").expect("task projected"); + assert_eq!(task.alias, "mixed-alias"); +} + +#[test] +fn legacy_mixed_case_alias_still_resolves_and_queries() { + let mut state = apply_events( + &create_empty_state(), + &[created( + "tsq-root0001", + json!({"title": "root", "alias": "stable-alias"}), + )], + ) + .expect("create should apply"); + state + .tasks + .get_mut("tsq-root0001") + .expect("task projected") + .alias = "Stable-Alias".to_string(); + + assert_eq!( + resolve_task_id(&state, "STABLE-ALIAS", false).expect("exact alias"), + "tsq-root0001" + ); + assert_eq!( + resolve_task_id(&state, "stable", false).expect("alias prefix"), + "tsq-root0001" + ); + + let filter = parse_query("alias:STABLE-ALIAS").expect("query parses"); + let tasks = state.tasks.values().cloned().collect::>(); + let matches = evaluate_query(&tasks, &filter, &state); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].id, "tsq-root0001"); + + let similar = is_blocking_duplicate("stable alias", &matches[0]).expect("alias similarity"); + assert_eq!(similar.reason, "alias_exact"); +} + #[test] fn task_updated_rejects_invalid_optional_typed_fields() { for (field, value) in [ diff --git a/tests/readable_identity.rs b/tests/readable_identity.rs new file mode 100644 index 0000000..1a61ac5 --- /dev/null +++ b/tests/readable_identity.rs @@ -0,0 +1,751 @@ +mod common; + +use common::{create_task, init_repo, run_cli, run_json}; +use serde_json::Value; +use std::fs; +use tasque::domain::similarity::DEFAULT_SIMILARITY_MIN_SCORE; + +#[test] +fn create_projects_stable_alias_from_title() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let id = create_task(repo.path(), "Improve task search warnings"); + let show = run_json(repo.path(), ["show", &id]); + + assert_eq!( + show.envelope["data"]["task"]["alias"].as_str(), + Some("improve-task-search-warnings") + ); +} + +#[test] +fn alias_collision_gets_numeric_suffix() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let first = create_task(repo.path(), "Improve task search warnings"); + let second = + common::create_task_with_args(repo.path(), "Improve task search warnings!", &["--force"]); + + let first_show = run_json(repo.path(), ["show", &first]); + let second_show = run_json(repo.path(), ["show", &second]); + + assert_eq!( + first_show.envelope["data"]["task"]["alias"].as_str(), + Some("improve-task-search-warnings") + ); + assert_eq!( + second_show.envelope["data"]["task"]["alias"].as_str(), + Some("improve-task-search-warnings-2") + ); +} + +#[test] +fn title_edit_does_not_recompute_alias() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let id = create_task(repo.path(), "Original alias title"); + let edit = run_json(repo.path(), ["edit", &id, "--title", "Renamed task"]); + assert_eq!(edit.cli.code, 0); + let show = run_json(repo.path(), ["show", &id]); + + assert_eq!( + show.envelope["data"]["task"]["alias"].as_str(), + Some("original-alias-title") + ); +} + +#[test] +fn new_root_ids_are_sequential() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let first = create_task(repo.path(), "First sequential task"); + let second = create_task(repo.path(), "Second sequential task"); + + assert_eq!(first, "tsq-1"); + assert_eq!(second, "tsq-2"); +} + +#[test] +fn child_ids_keep_parent_suffix_shape() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let parent = create_task(repo.path(), "Parent task"); + let child = run_json(repo.path(), ["create", "--parent", &parent, "Child task"]); + + assert_eq!(child.cli.code, 0); + assert_eq!( + child.envelope["data"]["task"]["id"].as_str(), + Some("tsq-1.1") + ); +} + +#[test] +fn explicit_sequential_id_is_allowed() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let result = run_json( + repo.path(), + ["create", "Explicit readable", "--id", "tsq-42"], + ); + + assert_eq!(result.cli.code, 0); + assert_eq!( + result.envelope["data"]["task"]["id"].as_str(), + Some("tsq-42") + ); +} + +#[test] +fn numeric_legacy_root_id_does_not_advance_sequential_allocation() { + let repo = common::make_repo(); + init_repo(repo.path()); + + common::create_task_with_args(repo.path(), "Legacy numeric ID", &["--id", "tsq-00000042"]); + let next = create_task(repo.path(), "First sequential after legacy"); + + assert_eq!(next, "tsq-1"); +} + +#[test] +fn commands_accept_exact_alias() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let id = create_task(repo.path(), "Improve task search warnings"); + let done = run_json(repo.path(), ["done", "improve-task-search-warnings"]); + + assert_eq!(done.cli.code, 0); + let show = run_json(repo.path(), ["show", &id]); + assert_eq!( + show.envelope["data"]["task"]["status"].as_str(), + Some("closed") + ); +} + +#[test] +fn commands_accept_alias_case_insensitively() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let id = create_task(repo.path(), "Improve task search warnings"); + let done = run_json(repo.path(), ["done", "IMPROVE-TASK-SEARCH-WARNINGS"]); + + assert_eq!(done.cli.code, 0); + let show = run_json(repo.path(), ["show", &id]); + assert_eq!( + show.envelope["data"]["task"]["status"].as_str(), + Some("closed") + ); +} + +#[test] +fn ambiguous_alias_prefix_returns_candidates() { + let repo = common::make_repo(); + init_repo(repo.path()); + + create_task(repo.path(), "Improve search duplicate warnings"); + create_task(repo.path(), "Improve search ranking"); + + let result = run_json(repo.path(), ["show", "improve-search"]); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("TASK_ID_AMBIGUOUS") + ); + assert!( + result.envelope["error"]["details"]["candidates"] + .as_array() + .expect("candidates") + .iter() + .all(|candidate| candidate.get("alias").is_some()) + ); +} + +#[test] +fn id_prefix_ambiguity_returns_id_and_alias_candidates() { + let repo = common::make_repo(); + init_repo(repo.path()); + + // Both get IDs starting with "tsq-", so prefix "tsq-" is ambiguous. + create_task(repo.path(), "First task"); + create_task(repo.path(), "Second task"); + + let result = run_json(repo.path(), ["show", "tsq-"]); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("TASK_ID_AMBIGUOUS") + ); + assert!( + result.envelope["error"]["details"]["candidates"] + .as_array() + .expect("candidates") + .iter() + .all(|candidate| candidate.get("id").is_some() && candidate.get("alias").is_some()) + ); +} + +#[test] +fn sequential_allocation_after_u64_max_returns_error() { + let repo = common::make_repo(); + init_repo(repo.path()); + + // Plant a task at u64::MAX so next allocation would overflow. + let max_id = "tsq-18446744073709551615"; + let setup = run_json(repo.path(), ["create", "Max ID task", "--id", max_id]); + assert_eq!(setup.cli.code, 0); + + let result = run_json(repo.path(), ["create", "Should overflow"]); + assert_eq!(result.cli.code, 2); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("ID_OVERFLOW") + ); +} + +#[test] +fn search_matches_alias_and_ranks_title_above_notes() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let title_hit = create_task(repo.path(), "Improve task search warnings"); + let notes_hit = create_task(repo.path(), "Unrelated task"); + let note = run_json( + repo.path(), + ["note", ¬es_hit, "mentions improve task search warnings"], + ); + assert_eq!(note.cli.code, 0); + + let result = run_json(repo.path(), ["find", "search", "improve search warnings"]); + + assert_eq!(result.cli.code, 0); + let ids = common::ids_from_task_list(&result.envelope); + assert_eq!(ids.first(), Some(&title_hit)); + assert!(ids.contains(¬es_hit)); +} + +#[test] +fn plain_text_search_matches_id_label_and_external_ref() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let id_hit = common::create_task_with_args(repo.path(), "ID-only target", &["--id", "tsq-42"]); + let label_hit = create_task(repo.path(), "Label-only target"); + let external_ref_hit = common::create_task_with_args( + repo.path(), + "External-only target", + &["--external-ref", "GH-1234"], + ); + let label = common::label_add(repo.path(), &label_hit, "ops-discovery"); + assert_eq!(label.cli.code, 0); + + let id_result = run_json(repo.path(), ["find", "search", "tsq-42"]); + let label_result = run_json(repo.path(), ["find", "search", "ops-discovery"]); + let external_ref_result = run_json(repo.path(), ["find", "search", "GH-1234"]); + + assert_eq!(id_result.cli.code, 0); + assert_eq!(label_result.cli.code, 0); + assert_eq!(external_ref_result.cli.code, 0); + assert!(common::ids_from_task_list(&id_result.envelope).contains(&id_hit)); + assert!(common::ids_from_task_list(&label_result.envelope).contains(&label_hit)); + assert!(common::ids_from_task_list(&external_ref_result.envelope).contains(&external_ref_hit)); +} + +#[test] +fn find_similar_returns_scores_and_reasons() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let id = create_task(repo.path(), "Improve task search warnings"); + create_task(repo.path(), "Package release automation"); + + let result = run_json(repo.path(), ["find", "similar", "task search warning"]); + + assert_eq!(result.cli.code, 0); + let candidates = result.envelope["data"]["candidates"] + .as_array() + .expect("candidates"); + assert_eq!(candidates[0]["task"]["id"].as_str(), Some(id.as_str())); + assert!(candidates[0]["score"].as_f64().expect("score") >= DEFAULT_SIMILARITY_MIN_SCORE); + assert!(!candidates[0]["reason"].as_str().expect("reason").is_empty()); +} + +#[test] +fn find_similar_matches_stable_alias_after_title_edit() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let id = create_task(repo.path(), "Original alias title"); + let edit = run_json(repo.path(), ["edit", &id, "--title", "Renamed title"]); + assert_eq!(edit.cli.code, 0); + + let result = run_json(repo.path(), ["find", "similar", "original alias title"]); + + assert_eq!(result.cli.code, 0); + let candidates = result.envelope["data"]["candidates"] + .as_array() + .expect("candidates"); + assert_eq!(candidates[0]["task"]["id"].as_str(), Some(id.as_str())); + assert_eq!(candidates[0]["reason"].as_str(), Some("alias_exact")); +} + +#[test] +fn find_similar_human_output_prints_header() { + let repo = common::make_repo(); + init_repo(repo.path()); + + create_task(repo.path(), "Improve task search warnings"); + + let result = run_cli(repo.path(), ["find", "similar", "task search warning"]); + + assert_eq!(result.code, 0); + assert!(result.stdout.lines().next().is_some_and(|line| { + line.contains("SCORE") + && line.contains("REASON") + && line.contains("ID") + && line.contains("TITLE") + })); +} + +#[test] +fn find_similar_whitespace_query_returns_empty_candidates() { + let repo = common::make_repo(); + init_repo(repo.path()); + + create_task(repo.path(), "Improve task search warnings"); + + let result = run_json(repo.path(), ["find", "similar", " "]); + + assert_eq!(result.cli.code, 0); + let candidates = result.envelope["data"]["candidates"] + .as_array() + .expect("candidates"); + assert!(candidates.is_empty()); +} + +#[test] +fn search_with_status_filter_still_ranks_title_above_notes() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let title_hit = create_task(repo.path(), "Improve task search warnings"); + let notes_hit = create_task(repo.path(), "Unrelated task"); + let note = run_json( + repo.path(), + ["note", ¬es_hit, "mentions improve task search warnings"], + ); + assert_eq!(note.cli.code, 0); + + let result = run_json( + repo.path(), + ["find", "search", "status:open improve search warnings"], + ); + + assert_eq!(result.cli.code, 0); + let ids = common::ids_from_task_list(&result.envelope); + assert_eq!(ids.first(), Some(&title_hit)); + assert!(ids.contains(¬es_hit)); +} + +#[test] +fn create_refuses_similar_open_task_without_force() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let existing = create_task(repo.path(), "Improve task search warnings"); + let result = run_json(repo.path(), ["create", "Improve search warning"]); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("DUPLICATE_TASK_CANDIDATE") + ); + assert_eq!( + result.envelope["error"]["details"]["candidates"][0]["id"].as_str(), + Some(existing.as_str()) + ); +} + +#[test] +fn create_force_bypasses_duplicate_gate() { + let repo = common::make_repo(); + init_repo(repo.path()); + + create_task(repo.path(), "Improve task search warnings"); + let forced = run_json(repo.path(), ["create", "Improve search warning", "--force"]); + + assert_eq!(forced.cli.code, 0); + assert_eq!( + forced.envelope["data"]["task"]["title"].as_str(), + Some("Improve search warning") + ); +} + +#[test] +fn create_force_records_minimal_duplicate_candidates_in_event_payload() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let existing = create_task(repo.path(), "Improve task search warnings"); + let forced = run_json(repo.path(), ["create", "Improve search warning", "--force"]); + assert_eq!(forced.cli.code, 0); + + let events = fs::read_to_string(repo.path().join(".tasque/events.jsonl")).expect("events"); + let forced_event: Value = serde_json::from_str(events.lines().last().expect("last event")) + .expect("forced event json"); + let candidate = &forced_event["payload"]["duplicate_candidates"][0]; + + assert_eq!(candidate["id"].as_str(), Some(existing.as_str())); + assert_eq!( + candidate["alias"].as_str(), + Some("improve-task-search-warnings") + ); + assert_eq!( + candidate["title"].as_str(), + Some("Improve task search warnings") + ); + assert_eq!(candidate["status"].as_str(), Some("open")); + assert!(candidate["score"].as_f64().is_some()); + assert!(candidate["reason"].as_str().is_some()); + assert!(candidate.get("task").is_none()); + assert!(candidate.get("notes").is_none()); + assert!(candidate.get("spec_fingerprint").is_none()); +} + +#[test] +fn create_rejects_ensure_force_combination() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let result = run_json( + repo.path(), + ["create", "Conflicting flags", "--ensure", "--force"], + ); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["message"].as_str(), + Some("cannot combine --ensure with --force") + ); +} + +#[test] +fn search_hard_filter_only_returns_deterministic_order() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let first = create_task(repo.path(), "Alpha task"); + let second = create_task(repo.path(), "Beta task"); + let third = create_task(repo.path(), "Gamma task"); + + let run1 = run_json(repo.path(), ["find", "search", "status:open"]); + let run2 = run_json(repo.path(), ["find", "search", "status:open"]); + + assert_eq!(run1.cli.code, 0); + let ids1 = common::ids_from_task_list(&run1.envelope); + let ids2 = common::ids_from_task_list(&run2.envelope); + assert_eq!(ids1, ids2, "hard-filter-only search must be deterministic"); + // Sorted by priority (all equal), then created_at, then id + assert_eq!(ids1, vec![first, second, third]); +} + +#[test] +fn multi_create_refuses_incoming_similar_duplicates() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let result = run_json( + repo.path(), + ["create", "Fix search warnings", "Fix search warning"], + ); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("DUPLICATE_TASK_CANDIDATE") + ); + // Verify no task was written (all-or-nothing) + let list = run_json(repo.path(), ["find", "search", "status:open"]); + assert_eq!(list.cli.code, 0); + let tasks = list.envelope["data"]["tasks"] + .as_array() + .expect("tasks array"); + assert!(tasks.is_empty(), "no tasks should have been created"); + // Verify candidates in error details + let details = &result.envelope["error"]["details"]; + assert!(details["candidates"].as_array().is_some()); + assert!(!details["candidates"].as_array().unwrap().is_empty()); +} + +#[test] +fn closed_similar_task_does_not_block_batch_create() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let existing = create_task(repo.path(), "Fix search warnings"); + let done = run_json(repo.path(), ["done", &existing]); + assert_eq!(done.cli.code, 0); + + let result = run_json( + repo.path(), + ["create", "Fix search warning", "Unrelated task"], + ); + + assert_eq!(result.cli.code, 0); + let tasks = result.envelope["data"]["tasks"] + .as_array() + .expect("tasks array"); + assert_eq!(tasks.len(), 2); +} + +#[test] +fn incoming_duplicate_error_includes_candidates() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let result = run_json( + repo.path(), + [ + "create", + "Improve search warnings", + "Improve search warning", + ], + ); + + assert_eq!(result.cli.code, 1); + let details = &result.envelope["error"]["details"]; + assert!(details["input_title"].as_str().is_some()); + let candidates = details["candidates"].as_array().expect("candidates array"); + assert!(!candidates.is_empty()); + assert!(candidates[0]["title"].as_str().is_some()); + assert!(candidates[0]["score"].as_f64().is_some()); + assert!(candidates[0]["reason"].as_str().is_some()); +} + +#[test] +fn ensure_batch_rejects_incoming_similar_duplicates() { + let repo = common::make_repo(); + init_repo(repo.path()); + + let result = run_json( + repo.path(), + [ + "create", + "--ensure", + "Improve search warnings", + "Improve search warning", + ], + ); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("DUPLICATE_TASK_CANDIDATE") + ); + // All-or-nothing: no tasks created + let list = run_json(repo.path(), ["find", "search", "status:open"]); + assert_eq!(list.cli.code, 0); + let tasks = list.envelope["data"]["tasks"] + .as_array() + .expect("tasks array"); + assert!(tasks.is_empty(), "no tasks should have been created"); +} + +#[test] +fn ensure_batch_rejects_existing_similar_task() { + let repo = common::make_repo(); + init_repo(repo.path()); + + // Pre-create a task that is similar but not an exact ensure-reusable match + create_task(repo.path(), "Improve task search warnings"); + + let result = run_json( + repo.path(), + [ + "create", + "--ensure", + "Improve search warning", + "Unrelated task", + ], + ); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("DUPLICATE_TASK_CANDIDATE") + ); +} + +#[test] +fn ensure_batch_allows_exact_reusable_match() { + let repo = common::make_repo(); + init_repo(repo.path()); + + // Pre-create a task that is an exact ensure-reusable match (same normalized title, same parent) + let existing = create_task(repo.path(), "Improve search warnings"); + + let result = run_json( + repo.path(), + [ + "create", + "--ensure", + "Improve search warnings", + "Unrelated task", + ], + ); + + assert_eq!(result.cli.code, 0); + let tasks = result.envelope["data"]["tasks"] + .as_array() + .expect("tasks array"); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0]["id"].as_str(), Some(existing.as_str())); + assert_ne!(tasks[1]["id"].as_str(), Some(existing.as_str())); + assert_eq!(tasks[1]["title"].as_str(), Some("Unrelated task")); +} + +#[test] +fn ensure_batch_rejects_wrong_parent_exact_match() { + let repo = common::make_repo(); + init_repo(repo.path()); + + // Pre-create a root-level task + create_task(repo.path(), "Shared child"); + + // from-file places "Shared child" under a new parent — different parent + // from the existing root task. Ensure must NOT silently exempt this + // candidate; all-or-nothing should reject before any writes. + let file = repo.path().join("tasks.md"); + std::fs::write(&file, "- New parent\n - Shared child\n").unwrap(); + + let result = run_json( + repo.path(), + ["create", "--from-file", "tasks.md", "--ensure"], + ); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("DUPLICATE_TASK_CANDIDATE"), + ); + + // All-or-nothing: "New parent" must NOT have been created either. + let list = run_json(repo.path(), ["find", "open"]); + let tasks = list.envelope["data"]["tasks"] + .as_array() + .expect("tasks array"); + let has_new_parent = tasks + .iter() + .any(|t| t["title"].as_str() == Some("New parent")); + assert!( + !has_new_parent, + "all-or-nothing violated: 'New parent' was created despite rejection" + ); +} + +#[test] +fn batch_create_is_atomic_on_later_duplicate_failure() { + let repo = common::make_repo(); + init_repo(repo.path()); + + // Pre-create a task that will collide with the second batch item + create_task(repo.path(), "Fix search warnings"); + + // Batch: first item is unique, second is a duplicate of existing + let result = run_json( + repo.path(), + ["create", "Unique new task", "Fix search warning"], + ); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("DUPLICATE_TASK_CANDIDATE") + ); + + // Atomicity: "Unique new task" must NOT have been created + let list = run_json(repo.path(), ["find", "open"]); + let tasks = list.envelope["data"]["tasks"] + .as_array() + .expect("tasks array"); + let has_unique = tasks + .iter() + .any(|t| t["title"].as_str() == Some("Unique new task")); + assert!( + !has_unique, + "all-or-nothing violated: 'Unique new task' was created despite batch rejection" + ); +} + +#[test] +fn explicit_id_still_checks_duplicate_gate() { + let repo = common::make_repo(); + init_repo(repo.path()); + + create_task(repo.path(), "Improve task search warnings"); + + let result = run_json( + repo.path(), + ["create", "Improve search warning", "--id", "tsq-99"], + ); + + assert_eq!(result.cli.code, 1); + assert_eq!( + result.envelope["error"]["code"].as_str(), + Some("DUPLICATE_TASK_CANDIDATE") + ); +} + +#[test] +fn human_list_output_shows_alias() { + let repo = common::make_repo(); + init_repo(repo.path()); + + create_task(repo.path(), "Improve task search warnings"); + let result = common::run_cli(repo.path(), ["find", "open"]); + + assert_eq!(result.code, 0); + assert!( + result.stdout.contains("ALIAS"), + "stdout:\n{}", + result.stdout + ); + assert!( + result.stdout.contains("improve-task-search-warnings"), + "stdout:\n{}", + result.stdout + ); +} + +#[test] +fn explicit_id_force_bypasses_duplicate_gate() { + let repo = common::make_repo(); + init_repo(repo.path()); + + create_task(repo.path(), "Improve task search warnings"); + + let result = run_json( + repo.path(), + [ + "create", + "Improve search warning", + "--id", + "tsq-99", + "--force", + ], + ); + + assert_eq!(result.cli.code, 0); + assert_eq!( + result.envelope["data"]["task"]["id"].as_str(), + Some("tsq-99") + ); +} diff --git a/tests/spec_workflow.rs b/tests/spec_workflow.rs index 8f0ea62..e70163d 100644 --- a/tests/spec_workflow.rs +++ b/tests/spec_workflow.rs @@ -86,6 +86,153 @@ fn spec_check_reports_fingerprint_drift_after_attached_file_edit() { ); } +#[test] +fn spec_update_replaces_spec_and_updates_fingerprint() { + let repo = common::make_repo(); + init_repo(repo.path()); + let task_id = create_task(repo.path(), "Spec update target"); + let attach = run_json(repo.path(), ["spec", &task_id, "--text", complete_spec()]); + assert_eq!(attach.cli.code, 0); + let old_fingerprint = attach.envelope["data"]["spec"]["spec_fingerprint"] + .as_str() + .expect("old fingerprint"); + + let updated = run_json( + repo.path(), + ["spec", &task_id, "--update", "--text", updated_spec()], + ); + + assert_eq!( + updated.cli.code, 0, + "spec update failed\nstdout:\n{}\nstderr:\n{}", + updated.cli.stdout, updated.cli.stderr + ); + let update_data = data(&updated.envelope); + assert_eq!( + update_data["spec"]["old_fingerprint"].as_str(), + Some(old_fingerprint) + ); + assert_ne!( + update_data["spec"]["new_fingerprint"].as_str(), + Some(old_fingerprint) + ); + let show = run_json(repo.path(), ["spec", &task_id, "--show"]); + assert_eq!( + show.envelope["data"]["spec"]["content"].as_str(), + Some(updated_spec()) + ); +} + +#[test] +fn spec_update_rejects_fingerprint_drift_before_replace() { + let repo = common::make_repo(); + init_repo(repo.path()); + let task_id = create_task(repo.path(), "Spec update drift target"); + let attach = run_json(repo.path(), ["spec", &task_id, "--text", complete_spec()]); + assert_eq!(attach.cli.code, 0); + let spec_path = attached_spec_path(repo.path(), data(&attach.envelope)); + fs::write(spec_path, format!("{}\n\nExtra drift.\n", complete_spec())).expect("edit spec"); + + let updated = run_json( + repo.path(), + ["spec", &task_id, "--update", "--text", updated_spec()], + ); + + assert_eq!(updated.cli.code, 1); + assert_eq!(error_code(&updated.envelope), Some("SPEC_CONFLICT")); + assert_eq!( + updated.envelope["error"]["details"]["task_id"].as_str(), + Some(task_id.as_str()) + ); +} + +#[test] +fn spec_patch_updates_existing_spec_in_memory() { + let repo = common::make_repo(); + init_repo(repo.path()); + let task_id = create_task(repo.path(), "Spec patch target"); + let attach = run_json(repo.path(), ["spec", &task_id, "--text", complete_spec()]); + assert_eq!(attach.cli.code, 0); + let patch = patch_overview( + &task_id, + "Complete direct workflow coverage.", + "Patched workflow coverage.", + ); + let patch_path = repo.path().join("spec.patch"); + fs::write(&patch_path, patch).expect("write patch"); + + let patched = run_json( + repo.path(), + ["spec", &task_id, "--patch", "--file", "spec.patch"], + ); + + assert_eq!( + patched.cli.code, 0, + "spec patch failed\nstdout:\n{}\nstderr:\n{}", + patched.cli.stdout, patched.cli.stderr + ); + let show = run_json(repo.path(), ["spec", &task_id, "--show"]); + assert!( + show.envelope["data"]["spec"]["content"] + .as_str() + .expect("content") + .contains("Patched workflow coverage.") + ); +} + +#[test] +fn spec_patch_rejects_fingerprint_drift_before_apply() { + let repo = common::make_repo(); + init_repo(repo.path()); + let task_id = create_task(repo.path(), "Spec patch drift target"); + let attach = run_json(repo.path(), ["spec", &task_id, "--text", complete_spec()]); + assert_eq!(attach.cli.code, 0); + let spec_path = attached_spec_path(repo.path(), data(&attach.envelope)); + fs::write(spec_path, format!("{}\n\nExtra drift.\n", complete_spec())).expect("edit spec"); + let patch = patch_overview( + &task_id, + "Complete direct workflow coverage.", + "Patched workflow coverage.", + ); + let patch_path = repo.path().join("drift.patch"); + fs::write(&patch_path, patch).expect("write patch"); + + let patched = run_json( + repo.path(), + ["spec", &task_id, "--patch", "--file", "drift.patch"], + ); + + assert_eq!(patched.cli.code, 1); + assert_eq!(error_code(&patched.envelope), Some("SPEC_CONFLICT")); + assert_eq!( + patched.envelope["error"]["details"]["task_id"].as_str(), + Some(task_id.as_str()) + ); +} + +#[test] +fn spec_patch_rejects_stale_context() { + let repo = common::make_repo(); + init_repo(repo.path()); + let task_id = create_task(repo.path(), "Spec stale patch target"); + let attach = run_json(repo.path(), ["spec", &task_id, "--text", complete_spec()]); + assert_eq!(attach.cli.code, 0); + let patch = patch_overview(&task_id, "Text from stale agent.", "New stale text."); + let patch_path = repo.path().join("stale.patch"); + fs::write(&patch_path, patch).expect("write patch"); + + let patched = run_json( + repo.path(), + ["spec", &task_id, "--patch", "--file", "stale.patch"], + ); + + assert_eq!(patched.cli.code, 1); + assert_eq!( + patched.envelope["error"]["code"].as_str(), + Some("SPEC_PATCH_FAILED") + ); +} + #[test] fn spec_check_reports_missing_required_sections_for_attached_incomplete_spec() { let repo = common::make_repo(); @@ -227,6 +374,47 @@ Missing required sections by design. "# } +fn updated_spec() -> &'static str { + r#"# Spec + +## Overview +Updated direct workflow coverage. + +## Constraints / Non-goals +No production behavior changes. + +## Interfaces (CLI/API) +Exercise tsq spec --update, tsq spec --patch, and tsq spec --check. + +## Data model / schema changes +No schema changes. + +## Acceptance criteria +Required spec checks pass before claim. + +## Test plan +Run cargo test --test spec_workflow --quiet. +"# +} + +fn patch_overview(task_id: &str, old: &str, new: &str) -> String { + let path = format!(".tasque/specs/{task_id}/spec.md"); + format!( + r#"--- {path} ++++ {path} +@@ -1,7 +1,7 @@ + # Spec + + ## Overview +-{old} ++{new} + + ## Constraints / Non-goals + No production behavior changes. +"# + ) +} + fn data(envelope: &Value) -> &Value { envelope.get("data").expect("missing data") } diff --git a/tests/sync_hooks_cli.rs b/tests/sync_hooks_cli.rs index e9799e6..3d5cd57 100644 --- a/tests/sync_hooks_cli.rs +++ b/tests/sync_hooks_cli.rs @@ -8,6 +8,10 @@ use std::process::Command; fn git(repo: &Path, args: &[&str]) { let output = Command::new("git") + // Per-invocation config keeps bare-repo/worktree tests portable when + // global Git safety config requires explicit bare repository trust. + .arg("-c") + .arg("safe.bareRepository=all") .args(args) .current_dir(repo) .output() @@ -23,6 +27,9 @@ fn git(repo: &Path, args: &[&str]) { fn git_out(repo: &Path, args: &[&str]) -> String { let output = Command::new("git") + // Same per-command safety override as git(); this helper captures stdout. + .arg("-c") + .arg("safe.bareRepository=all") .args(args) .current_dir(repo) .output() diff --git a/tests/verb_first_task_commands.rs b/tests/verb_first_task_commands.rs index 79d312d..5f197ef 100644 --- a/tests/verb_first_task_commands.rs +++ b/tests/verb_first_task_commands.rs @@ -31,6 +31,43 @@ fn create_from_file_accepts_markdown_bullets() { assert_eq!(tasks[1]["title"].as_str(), Some("Wire CLI command")); } +#[test] +fn create_from_file_allocates_root_ids_sequentially_after_high_existing_id() { + let repo = common::make_repo(); + init_repo(repo.path()); + create_task_with_args(repo.path(), "Existing high root", &["--id", "tsq-42"]); + let file = repo.path().join("tasks.md"); + std::fs::write(&file, "- First root\n- Second root\n").unwrap(); + + let result = run_json(repo.path(), ["create", "--from-file", "tasks.md"]); + + assert_eq!(result.cli.code, 0); + let tasks = result.envelope["data"]["tasks"].as_array().expect("tasks"); + assert_eq!(tasks[0]["id"].as_str(), Some("tsq-43")); + assert_eq!(tasks[1]["id"].as_str(), Some("tsq-44")); +} + +#[test] +fn create_from_file_can_allocate_last_u64_root_id() { + let repo = common::make_repo(); + init_repo(repo.path()); + create_task_with_args( + repo.path(), + "Penultimate root", + &["--id", "tsq-18446744073709551614"], + ); + let file = repo.path().join("tasks.md"); + std::fs::write(&file, "- Last root\n").unwrap(); + + let result = run_json(repo.path(), ["create", "--from-file", "tasks.md"]); + + assert_eq!(result.cli.code, 0); + assert_eq!( + result.envelope["data"]["task"]["id"].as_str(), + Some("tsq-18446744073709551615") + ); +} + #[test] fn create_from_file_maps_nested_bullets_to_parent_ids() { let repo = common::make_repo();