Skip to content

Commit d5949ff

Browse files
foharaclaude
andcommitted
Fix file-level label filtering
File-level labels (@labels in file metadata) were being parsed and stored but not indexed or queryable. This commit enables full support for filtering tasks by file-level labels. Changes: - Index file-level labels into labels and file_labels tables during indexing - Update TaskFilter::find() to check both task_labels and file_labels - Update find_by_label() to search both task and file-level labels - Add comprehensive test_file_level_label_filtering() integration test - Update snapshot test to reflect correct label filtering behavior Now `lash list --label art` returns all tasks from files labeled with "art", not just tasks with inline #art tags. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a4a9074 commit d5949ff

4 files changed

Lines changed: 243 additions & 10 deletions

File tree

‎crates/lash-cli/tests/snapshots/regression_tests__list_backend_label.snap‎

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,53 @@
22
source: crates/lash-cli/tests/regression_tests.rs
33
expression: normalized
44
---
5-
ID  STATUS  TITLE  LABELS
6-
-------------------------------------------------------------------------------------------------
7-
medium-project#backend-development-backend open Backend development #backend backend
5+
ID  STATUS  TITLE  LABELS
6+
----------------------------------------------------------------------------------------------------------------------
7+
backend.api#add-api-documentation-docs  open Add API documentation #docs docs
8+
backend.api#define-endpoints  done Define endpoints
9+
backend.api#design-requestresponse-schemas  done Design request/response schemas
10+
backend.api#design-rest-api  done Design REST API
11+
backend.api#example-requests  open Example requests
12+
backend.api#implement-authentication-security  open Implement authentication #security security
13+
backend.api#implement-crud-endpoints-core  open Implement CRUD endpoints #core core
14+
backend.api#jwt-token-generation  done JWT token generation
15+
backend.api#oauth2-integration-p1  open OAuth2 integration #p1 p1
16+
backend.api#openapi-spec  open OpenAPI spec
17+
backend.api#project-endpoints  open Project endpoints
18+
backend.api#session-management  open Session management
19+
backend.api#task-endpoints  open Task endpoints
20+
backend.api#user-endpoints  done User endpoints
21+
backend.database#add-database-layer-core  done Add database layer #core core
22+
backend.database#add-indexes  done Add indexes
23+
backend.database#connection-pooling  done Connection pooling
24+
backend.database#design-database-schema  done Design database schema
25+
backend.database#initial-schema-migration  done Initial schema migration
26+
backend.database#migration-framework  done Migration framework
27+
backend.database#performance-optimization-performance done Performance optimization #performance performance
28+
backend.database#projects-table  done Projects table
29+
backend.database#query-builders  done Query builders
30+
backend.database#query-optimization  done Query optimization
31+
backend.database#relationships  done Relationships
32+
backend.database#seed-data  done Seed data
33+
backend.database#set-up-migrations-infrastructure  done Set up migrations #infrastructure infrastructure
34+
backend.database#tasks-table  done Tasks table
35+
backend.database#transaction-support  done Transaction support
36+
backend.database#users-table  done Users table
37+
backend.tests#api-endpoint-tests  done API endpoint tests
38+
backend.tests#auth-flow-tests  open Auth flow tests
39+
backend.tests#database-integration-tests  open Database integration tests
40+
backend.tests#helper-functions  done Helper functions
41+
backend.tests#integration-tests-integration-testing  open Integration tests #integration-testing integration-testing
42+
backend.tests#load-testing  open Load testing
43+
backend.tests#model-tests  done Model tests
44+
backend.tests#performance-tests-performance  open Performance tests #performance performance
45+
backend.tests#service-tests  open Service tests
46+
backend.tests#set-up-test-infrastructure  done Set up test infrastructure
47+
backend.tests#stress-testing  open Stress testing
48+
backend.tests#test-database  done Test database
49+
backend.tests#test-fixtures  done Test fixtures
50+
backend.tests#unit-tests-unit-testing  open Unit tests #unit-testing unit-testing
51+
backend.tests#utility-tests  open Utility tests
52+
medium-project#backend-development-backend  open Backend development #backend backend
853

9-
[1mTotal: 1 task(s)[0m
54+
[1mTotal: 46 task(s)[0m

‎crates/lash-db/src/indexer.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,18 @@ impl<'conn> Indexer<'conn> {
618618
.get(&task_file.path)
619619
.ok_or_else(|| DbError::Other("File ID not found after upsert".to_string()))?;
620620

621+
// Index file-level labels
622+
let start = Instant::now();
623+
for label in &task_file.metadata.labels {
624+
let label_id = label_repo.get_or_create(label)?;
625+
label_repo.add_file_label(*file_db_id, label_id)?;
626+
}
627+
profiler.record_db_operation(
628+
"index_file_labels",
629+
task_file.metadata.labels.len(),
630+
start.elapsed(),
631+
);
632+
621633
// Delete existing tasks for this file (ensures clean re-index)
622634
self.conn
623635
.execute("DELETE FROM tasks WHERE file_id = ?1", [file_db_id])?;

‎crates/lash-db/src/repository/tasks.rs‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,20 @@ impl<'conn> TaskRepository<'conn> {
289289

290290
/// Find tasks by label
291291
///
292+
/// Finds tasks that have the label directly (via `task_labels`) OR
293+
/// are in a file with the label (via `file_labels`).
294+
///
292295
/// # Errors
293296
///
294297
/// Returns error if query fails
295298
pub fn find_by_label(&self, label: &str) -> DbResult<Vec<TaskRecord>> {
296299
let mut stmt = self.conn.prepare(
297-
"SELECT t.id, t.file_id, t.local_id, t.full_id, t.title, t.status, t.depth, t.parent_id, t.order_index, t.owner, t.estimate, t.body, t.metadata
300+
"SELECT DISTINCT t.id, t.file_id, t.local_id, t.full_id, t.title, t.status, t.depth, t.parent_id, t.order_index, t.owner, t.estimate, t.body, t.metadata
298301
FROM tasks t
299-
JOIN task_labels tl ON t.id = tl.task_id
300-
JOIN labels l ON tl.label_id = l.id
302+
JOIN files f ON t.file_id = f.id
303+
LEFT JOIN task_labels tl ON t.id = tl.task_id
304+
LEFT JOIN file_labels fl ON f.id = fl.file_id
305+
JOIN labels l ON (l.id = tl.label_id OR l.id = fl.label_id)
301306
WHERE l.name = ?1
302307
ORDER BY t.full_id",
303308
)?;
@@ -398,16 +403,22 @@ impl<'conn> TaskRepository<'conn> {
398403
let mut wheres = Vec::new();
399404

400405
// Add joins for labels if needed
406+
// Match tasks that have the label directly OR are in a file with the label
401407
if !filter.labels.is_empty() {
408+
// Always join files table when filtering by labels (needed for file_labels lookup)
409+
if filter.file_path.is_none() {
410+
joins.push("JOIN files f ON t.file_id = f.id".to_string());
411+
}
402412
joins.push(
403-
"JOIN task_labels tl ON t.id = tl.task_id
404-
JOIN labels l ON tl.label_id = l.id"
413+
"LEFT JOIN task_labels tl ON t.id = tl.task_id
414+
LEFT JOIN file_labels fl ON f.id = fl.file_id
415+
JOIN labels l ON (l.id = tl.label_id OR l.id = fl.label_id)"
405416
.to_string(),
406417
);
407418
}
408419

409420
// Add joins for file path if needed
410-
if filter.file_path.is_some() {
421+
if filter.file_path.is_some() && filter.labels.is_empty() {
411422
joins.push("JOIN files f ON t.file_id = f.id".to_string());
412423
}
413424

‎crates/lash-db/tests/index_query_workflow.rs‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,3 +659,168 @@ fn test_index_multi_file_cross_queries() {
659659
"Should have tasks from at least 4 different files"
660660
);
661661
}
662+
663+
#[test]
664+
fn test_file_level_label_filtering() {
665+
// Test scenario: Verify file-level labels are indexed and filterable
666+
// This test explicitly verifies that @labels from file metadata are:
667+
// 1. Indexed into the labels and file_labels tables
668+
// 2. Queryable via TaskFilter
669+
// 3. Properly associated with all tasks in the file
670+
let project = create_multi_file_project();
671+
let root = project.path().to_path_buf();
672+
let db = TestDatabase::file_based();
673+
let conn = db.connection();
674+
675+
// Index the project
676+
let config = IndexerConfig::new(root.clone())
677+
.with_incremental(false)
678+
.with_progress(false);
679+
let parser_config = LashConfig::default();
680+
let mut indexer = Indexer::new(&conn, config, &parser_config);
681+
indexer.index_project().expect("Index should succeed");
682+
683+
// Verify file-level labels are in the labels table
684+
let inspector = DbInspector::new(&conn);
685+
let labels = inspector.get_labels();
686+
assert!(
687+
!labels.is_empty(),
688+
"File-level labels should be indexed into labels table"
689+
);
690+
691+
// Verify specific labels exist
692+
assert!(
693+
labels.contains(&"backend".to_string()),
694+
"Should have 'backend' label from auth.md and database.md"
695+
);
696+
assert!(
697+
labels.contains(&"frontend".to_string()),
698+
"Should have 'frontend' label from ui.md"
699+
);
700+
assert!(
701+
labels.contains(&"security".to_string()),
702+
"Should have 'security' label from auth.md"
703+
);
704+
705+
// Test 1: Filter by file-level label "backend"
706+
let task_repo = TaskRepository::new(&conn);
707+
let backend_tasks = task_repo
708+
.find(&TaskFilter {
709+
labels: vec!["backend".to_string()],
710+
..Default::default()
711+
})
712+
.expect("Should find tasks by file-level label");
713+
714+
// Should get ALL tasks from auth.md (5 tasks) and database.md (3 tasks) = 8 tasks
715+
assert!(
716+
backend_tasks.len() >= 5,
717+
"Should have at least 5 tasks from backend-labeled files, got {}",
718+
backend_tasks.len()
719+
);
720+
721+
// Verify all tasks are from backend-labeled files
722+
let file_repo = FileRepository::new(&conn);
723+
for task in &backend_tasks {
724+
let file = file_repo
725+
.get_by_db_id(task.file_id)
726+
.expect("Should get file")
727+
.expect("File should exist");
728+
let path = file.path.to_string_lossy();
729+
assert!(
730+
path.contains("auth.md") || path.contains("database.md"),
731+
"Backend task should be from auth.md or database.md, got: {path}"
732+
);
733+
}
734+
735+
// Test 2: Filter by file-level label "frontend"
736+
let frontend_tasks = task_repo
737+
.find(&TaskFilter {
738+
labels: vec!["frontend".to_string()],
739+
..Default::default()
740+
})
741+
.expect("Should find tasks by file-level label");
742+
743+
// Should get ALL tasks from ui.md (3 tasks)
744+
assert_eq!(
745+
frontend_tasks.len(),
746+
3,
747+
"Should have exactly 3 tasks from ui.md"
748+
);
749+
750+
// Verify all tasks are from ui.md
751+
for task in &frontend_tasks {
752+
let file = file_repo
753+
.get_by_db_id(task.file_id)
754+
.expect("Should get file")
755+
.expect("File should exist");
756+
assert!(
757+
file.path.to_string_lossy().contains("ui.md"),
758+
"Frontend task should be from ui.md"
759+
);
760+
}
761+
762+
// Test 3: Filter by file-level label "bug"
763+
let bug_tasks = task_repo
764+
.find(&TaskFilter {
765+
labels: vec!["bug".to_string()],
766+
..Default::default()
767+
})
768+
.expect("Should find tasks by file-level label");
769+
770+
// Should get ALL tasks from bugs.md (3 tasks)
771+
assert_eq!(
772+
bug_tasks.len(),
773+
3,
774+
"Should have exactly 3 tasks from bugs.md"
775+
);
776+
777+
// Test 4: Filter by file-level label "security" (only on auth.md)
778+
let security_tasks = task_repo
779+
.find(&TaskFilter {
780+
labels: vec!["security".to_string()],
781+
..Default::default()
782+
})
783+
.expect("Should find tasks by file-level label");
784+
785+
// Should get ALL tasks from auth.md (5 tasks)
786+
assert_eq!(
787+
security_tasks.len(),
788+
5,
789+
"Should have exactly 5 tasks from auth.md"
790+
);
791+
792+
// Test 5: Combine file-level label with status filter
793+
let open_backend_tasks = task_repo
794+
.find(&TaskFilter {
795+
labels: vec!["backend".to_string()],
796+
status: Some(TaskStatus::Open),
797+
..Default::default()
798+
})
799+
.expect("Should find tasks with combined filters");
800+
801+
// Verify all results match both criteria
802+
for task in &open_backend_tasks {
803+
assert_eq!(task.status, TaskStatus::Open, "Should only be open tasks");
804+
let file = file_repo
805+
.get_by_db_id(task.file_id)
806+
.expect("Should get file")
807+
.expect("File should exist");
808+
let path = file.path.to_string_lossy();
809+
assert!(
810+
path.contains("auth.md") || path.contains("database.md"),
811+
"Should be from backend-labeled file"
812+
);
813+
}
814+
815+
// Test 6: Verify no cross-contamination between different file-level labels
816+
let backend_ids: std::collections::HashSet<_> =
817+
backend_tasks.iter().map(|t| &t.full_id).collect();
818+
let frontend_ids: std::collections::HashSet<_> =
819+
frontend_tasks.iter().map(|t| &t.full_id).collect();
820+
821+
let overlap: Vec<_> = backend_ids.intersection(&frontend_ids).collect();
822+
assert!(
823+
overlap.is_empty(),
824+
"Backend and frontend tasks should not overlap (different files), found: {overlap:?}"
825+
);
826+
}

0 commit comments

Comments
 (0)