Skip to content

Commit c8086ce

Browse files
committed
Support multiple -t flags for AND tag filtering
ls, ready, blocked, and stats now accept repeated -t flags and apply AND logic across all specified tags. A shared build_tag_clauses helper generates the EXISTS SQL clauses. cmd_stats uses indexed JOIN aliases to avoid ambiguity when filtering on multiple tags.
1 parent 43c37f4 commit c8086ce

2 files changed

Lines changed: 40 additions & 28 deletions

File tree

src/commands.pact

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ fn sql_escape(s: Str) -> Str {
1818
s.replace("'", "''")
1919
}
2020

21+
fn build_tag_clauses(tag_filters: List[Str]) -> List[Str] {
22+
let mut clauses: List[Str] = []
23+
for tag in tag_filters {
24+
let tf = sql_escape(tag)
25+
clauses.push("EXISTS (SELECT 1 FROM tags tg WHERE tg.task_id = t.id AND tg.tag = '{tf}')")
26+
}
27+
clauses
28+
}
29+
2130
fn is_empty_json(s: Str) -> Bool {
2231
s.is_empty() || s == "[]"
2332
}
@@ -118,7 +127,7 @@ pub fn cmd_add(title: Str, description: Str, priority: Int, tag_list: List[Str],
118127
io.println("Created: {short_id(id)} {title}")
119128
}
120129

121-
pub fn cmd_ls(status_filter: Str, tag_filter: Str, json_mode: Bool, show_closed: Bool, show_all: Bool) {
130+
pub fn cmd_ls(status_filter: Str, tag_filters: List[Str], json_mode: Bool, show_closed: Bool, show_all: Bool) {
122131
let mut where_parts: List[Str] = []
123132
if !status_filter.is_empty() {
124133
let sf = sql_escape(status_filter)
@@ -128,9 +137,8 @@ pub fn cmd_ls(status_filter: Str, tag_filter: Str, json_mode: Bool, show_closed:
128137
} else if !show_all {
129138
where_parts.push("t.status NOT IN ('done', 'cancelled')")
130139
}
131-
if !tag_filter.is_empty() {
132-
let tf = sql_escape(tag_filter)
133-
where_parts.push("EXISTS (SELECT 1 FROM tags tg WHERE tg.task_id = t.id AND tg.tag = '{tf}')")
140+
for clause in build_tag_clauses(tag_filters) {
141+
where_parts.push(clause)
134142
}
135143
let where_clause = if where_parts.len() == 0 { "" } else { " WHERE {where_parts.join(" AND ")}" }
136144
let sql = "SELECT t.id, t.title, t.priority, t.status, COALESCE(GROUP_CONCAT(tg.tag, ', '), '') as tags FROM tasks t LEFT JOIN tags tg ON tg.task_id = t.id{where_clause} GROUP BY t.id ORDER BY t.priority ASC, t.created_at ASC"
@@ -283,11 +291,11 @@ pub fn cmd_rm(id_prefix: Str) {
283291

284292
// --- DAG Commands ---
285293

286-
pub fn cmd_ready(tag_filter: Str, json_mode: Bool) {
294+
pub fn cmd_ready(tag_filters: List[Str], json_mode: Bool) {
287295
let mut tag_clause = ""
288-
if !tag_filter.is_empty() {
289-
let tf = sql_escape(tag_filter)
290-
tag_clause = " AND EXISTS (SELECT 1 FROM tags tg WHERE tg.task_id = t.id AND tg.tag = '{tf}')"
296+
let tag_clauses = build_tag_clauses(tag_filters)
297+
if !tag_clauses.is_empty() {
298+
tag_clause = " AND {tag_clauses.join(" AND ")}"
291299
}
292300
let sql = "SELECT t.id, t.title, t.priority, t.status, COALESCE(GROUP_CONCAT(tg.tag, ', '), '') as tags FROM tasks t LEFT JOIN tags tg ON tg.task_id = t.id WHERE t.status = 'open' AND NOT EXISTS (SELECT 1 FROM deps d JOIN tasks blocker ON blocker.id = d.blocker_id WHERE d.blocked_id = t.id AND blocker.status NOT IN ('done', 'cancelled')){tag_clause} GROUP BY t.id ORDER BY t.priority ASC, t.created_at ASC"
293301
let result = db_query(sql)
@@ -364,11 +372,11 @@ pub fn cmd_dep_rm(blocker_prefix: Str, blocked_prefix: Str) {
364372
io.println("Removed: {short_id(blocker_id)} no longer blocks {short_id(blocked_id)}")
365373
}
366374

367-
pub fn cmd_blocked(tag_filter: Str, json_mode: Bool) {
375+
pub fn cmd_blocked(tag_filters: List[Str], json_mode: Bool) {
368376
let mut tag_clause = ""
369-
if !tag_filter.is_empty() {
370-
let tf = sql_escape(tag_filter)
371-
tag_clause = " AND EXISTS (SELECT 1 FROM tags tg2 WHERE tg2.task_id = t.id AND tg2.tag = '{tf}')"
377+
let tag_clauses = build_tag_clauses(tag_filters)
378+
if !tag_clauses.is_empty() {
379+
tag_clause = " AND {tag_clauses.join(" AND ")}"
372380
}
373381
let sql = "SELECT t.id, t.title, t.priority, t.status, COALESCE(GROUP_CONCAT(DISTINCT tg.tag), '') as tags, COALESCE(GROUP_CONCAT(DISTINCT d.blocker_id), '') as blockers FROM tasks t JOIN deps d ON d.blocked_id = t.id LEFT JOIN tags tg ON tg.task_id = t.id WHERE t.status NOT IN ('done', 'cancelled'){tag_clause} GROUP BY t.id ORDER BY t.priority ASC"
374382
let result = db_query(sql)
@@ -440,14 +448,18 @@ pub fn cmd_tags() {
440448
}
441449
}
442450

443-
pub fn cmd_stats(tag_filter: Str) {
444-
let mut tag_join = ""
445-
let mut tag_where = ""
446-
if !tag_filter.is_empty() {
447-
let tf = sql_escape(tag_filter)
448-
tag_join = " JOIN tags tg ON tg.task_id = t.id"
449-
tag_where = " WHERE tg.tag = '{tf}'"
450-
}
451+
pub fn cmd_stats(tag_filters: List[Str]) {
452+
let mut tag_joins: List[Str] = []
453+
let mut tag_wheres: List[Str] = []
454+
let mut ti = 0
455+
for tag in tag_filters {
456+
let tf = sql_escape(tag)
457+
tag_joins.push(" JOIN tags tg{ti} ON tg{ti}.task_id = t.id")
458+
tag_wheres.push("tg{ti}.tag = '{tf}'")
459+
ti = ti + 1
460+
}
461+
let tag_join = tag_joins.join("")
462+
let tag_where = if tag_wheres.is_empty() { "" } else { " WHERE {tag_wheres.join(" AND ")}" }
451463
let result = db_query("SELECT t.status, COUNT(*) as count FROM tasks t{tag_join}{tag_where} GROUP BY t.status ORDER BY t.status")
452464
if is_empty_json(result) {
453465
io.println("No tasks.")

src/main.pact

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,13 @@ fn main() {
142142
cmd_add(title, description, priority, tags, session_id)
143143
} else if cmd == "ls" || cmd == "list" {
144144
let status_filter = args_get(a, "s")
145-
let tag_filter = args_get(a, "t")
145+
let tag_filters = args_get_all(a, "t")
146146
let show_closed = args_has(a, "closed")
147147
let show_all = args_has(a, "all")
148-
cmd_ls(status_filter, tag_filter, json_mode, show_closed, show_all)
148+
cmd_ls(status_filter, tag_filters, json_mode, show_closed, show_all)
149149
} else if cmd == "ready" {
150-
let tag_filter = args_get(a, "t")
151-
cmd_ready(tag_filter, json_mode)
150+
let tag_filters = args_get_all(a, "t")
151+
cmd_ready(tag_filters, json_mode)
152152
} else if cmd == "show" {
153153
let id = args_positional(a, 0)
154154
if id == "" {
@@ -224,8 +224,8 @@ fn main() {
224224
} else if cmd == "dep" {
225225
io.println(generate_command_help(p, "dep"))
226226
} else if cmd == "blocked" {
227-
let tag_filter = args_get(a, "t")
228-
cmd_blocked(tag_filter, json_mode)
227+
let tag_filters = args_get_all(a, "t")
228+
cmd_blocked(tag_filters, json_mode)
229229
} else if cmd == "tag" {
230230
let id = args_positional(a, 0)
231231
let tags = args_positionals_from(a, 1)
@@ -245,8 +245,8 @@ fn main() {
245245
} else if cmd == "tags" {
246246
cmd_tags()
247247
} else if cmd == "stats" {
248-
let tag_filter = args_get(a, "t")
249-
cmd_stats(tag_filter)
248+
let tag_filters = args_get_all(a, "t")
249+
cmd_stats(tag_filters)
250250
} else if cmd == "install" {
251251
cmd_install()
252252
} else if cmd == "uninstall" {

0 commit comments

Comments
 (0)