1use std::collections::{HashMap, HashSet};
2use std::ffi::OsStr;
3use std::fs::File;
4use std::io::{BufRead, BufReader};
5use std::path::{Component, Path, PathBuf};
6use std::sync::atomic::{AtomicBool, Ordering};
7
8use crate::model::{Issue, Sprint};
9use crate::{BvrError, Result};
10use serde::Deserialize;
11
12pub const BEADS_DIR_ENV: &str = "BEADS_DIR";
13static ROBOT_WARNING_SUPPRESSION: AtomicBool = AtomicBool::new(false);
14
15const PREFERRED_JSONL_NAMES: &[&str] = &["beads.jsonl", "issues.jsonl", "beads.base.jsonl"];
16const MAX_LINE_BYTES: usize = 10 * 1024 * 1024;
17pub const SPRINTS_FILE_NAME: &str = "sprints.jsonl";
18pub const WORKSPACE_CONFIG_PATH: &str = ".bv/workspace.yaml";
19const DEFAULT_WORKSPACE_DISCOVERY_PATTERNS: &[&str] = &[
20 "*",
21 "packages/*",
22 "apps/*",
23 "services/*",
24 "libs/*",
25 "modules/*",
26];
27const DEFAULT_WORKSPACE_EXCLUDE_PATTERNS: &[&str] =
28 &["node_modules", "vendor", ".git", "dist", "build", "target"];
29const DEFAULT_WORKSPACE_DISCOVERY_MAX_DEPTH: usize = 2;
30
31#[must_use]
32pub fn is_robot_mode() -> bool {
33 ROBOT_WARNING_SUPPRESSION.load(Ordering::Relaxed)
34 || std::env::var("BV_ROBOT").is_ok_and(|value| value == "1")
35}
36
37pub fn set_robot_warning_suppression(enabled: bool) {
38 ROBOT_WARNING_SUPPRESSION.store(enabled, Ordering::Relaxed);
39}
40
41#[derive(Debug, Clone, Deserialize, Default)]
42pub struct WorkspaceConfig {
43 #[serde(default)]
44 pub name: String,
45 #[serde(default)]
46 pub repos: Vec<WorkspaceRepoConfig>,
47 #[serde(default)]
48 pub discovery: WorkspaceDiscoveryConfig,
49 #[serde(default)]
50 pub defaults: WorkspaceDefaultsConfig,
51}
52
53#[derive(Debug, Clone, Deserialize, Default)]
54pub struct WorkspaceRepoConfig {
55 #[serde(default)]
56 pub name: String,
57 #[serde(default)]
58 pub path: String,
59 #[serde(default)]
60 pub prefix: String,
61 #[serde(default)]
62 pub beads_path: String,
63 #[serde(default)]
64 pub enabled: Option<bool>,
65}
66
67#[derive(Debug, Clone, Deserialize, Default)]
68pub struct WorkspaceDiscoveryConfig {
69 #[serde(default)]
70 pub enabled: bool,
71 #[serde(default)]
72 pub patterns: Vec<String>,
73 #[serde(default)]
74 pub exclude: Vec<String>,
75 #[serde(default)]
76 pub max_depth: usize,
77}
78
79#[derive(Debug, Clone, Deserialize, Default)]
80pub struct WorkspaceDefaultsConfig {
81 #[serde(default)]
82 pub beads_path: String,
83}
84
85#[derive(Debug, Clone, Default)]
86pub struct WorkspaceLoadSummary {
87 pub total_repos: usize,
88 pub successful_repos: usize,
89 pub failed_repos: usize,
90 pub total_issues: usize,
91 pub failed_repo_names: Vec<String>,
92 pub repo_prefixes: Vec<String>,
93}
94
95#[derive(Debug, Clone)]
96struct WorkspaceRepoLoadResult {
97 repo_name: String,
98 prefix: String,
99 issues: Vec<Issue>,
100 error: Option<String>,
101}
102
103impl WorkspaceRepoConfig {
104 fn is_enabled(&self) -> bool {
105 self.enabled.unwrap_or(true)
106 }
107
108 pub fn effective_name(&self) -> String {
109 if !self.name.trim().is_empty() {
110 return self.name.trim().to_string();
111 }
112
113 Path::new(self.path.trim())
114 .file_name()
115 .and_then(OsStr::to_str)
116 .unwrap_or_else(|| self.path.trim())
117 .to_string()
118 }
119
120 pub fn effective_prefix(&self) -> String {
121 if !self.prefix.trim().is_empty() {
122 return self.prefix.trim().to_string();
123 }
124
125 let fallback = self.effective_name();
126 format!("{}-", fallback.to_ascii_lowercase())
127 }
128
129 pub fn effective_beads_path(&self, defaults: Option<&WorkspaceDefaultsConfig>) -> String {
130 if !self.beads_path.trim().is_empty() {
131 self.beads_path.trim().to_string()
132 } else if let Some(defaults) = defaults
133 && !defaults.beads_path.trim().is_empty()
134 {
135 defaults.beads_path.trim().to_string()
136 } else {
137 ".beads".to_string()
138 }
139 }
140}
141
142impl WorkspaceConfig {
143 fn apply_defaults(&mut self) {
144 if self.discovery.enabled {
145 if self.discovery.patterns.is_empty() {
146 self.discovery.patterns = DEFAULT_WORKSPACE_DISCOVERY_PATTERNS
147 .iter()
148 .map(|pattern| (*pattern).to_string())
149 .collect();
150 }
151 if self.discovery.exclude.is_empty() {
152 self.discovery.exclude = DEFAULT_WORKSPACE_EXCLUDE_PATTERNS
153 .iter()
154 .map(|pattern| (*pattern).to_string())
155 .collect();
156 }
157 if self.discovery.max_depth == 0 {
158 self.discovery.max_depth = DEFAULT_WORKSPACE_DISCOVERY_MAX_DEPTH;
159 }
160 }
161 }
162
163 fn resolve_repos(
164 &self,
165 workspace_root: &Path,
166 config_path: &Path,
167 ) -> Result<Vec<WorkspaceRepoConfig>> {
168 let mut repos = self.repos.clone();
169 if self.discovery.enabled {
170 repos.extend(discover_workspace_repos(
171 workspace_root,
172 &self.discovery,
173 &self.defaults,
174 &repos,
175 )?);
176
177 if repos.is_empty() {
178 let searched_patterns = self.discovery.patterns.join(", ");
179 let excludes = self.discovery.exclude.join(", ");
180 return Err(BvrError::InvalidArgument(format!(
181 "workspace discovery found no repositories for {}.\n\
182 Searched root: {}\n\
183 Patterns: [{}]\n\
184 Exclude: [{}]\n\
185 Max depth: {}\n\
186 Remediation:\n\
187 1. Add explicit repos: entries to {}.\n\
188 2. Adjust discovery.patterns or defaults.beads_path to match your layout.\n\
189 3. Or rerun with --workspace <path-to-.bv/workspace.yaml> pointing at a config with explicit repos.",
190 config_path.display(),
191 workspace_root.display(),
192 searched_patterns,
193 excludes,
194 self.discovery.max_depth,
195 config_path.display(),
196 )));
197 }
198 }
199
200 for repo in &mut repos {
201 if !repo.is_enabled() || !repo.name.trim().is_empty() {
202 continue;
203 }
204
205 repo.name = inferred_repo_name(Path::new(repo.path.trim()), workspace_root);
206 }
207
208 let mut seen_repo_paths = HashSet::<String>::new();
209 for (index, repo) in repos.iter().enumerate() {
210 if !repo.is_enabled() {
211 continue;
212 }
213
214 let identity = repo_identity_key(Path::new(repo.path.trim()), workspace_root);
215 if !seen_repo_paths.insert(identity.clone()) {
216 return Err(BvrError::InvalidArgument(format!(
217 "workspace repo[{index}] duplicates repository path '{identity}'"
218 )));
219 }
220 }
221
222 Ok(repos)
223 }
224
225 fn validate(&self) -> Result<()> {
226 if self.repos.is_empty() {
227 return Err(BvrError::InvalidArgument(
228 "workspace must define at least one repository".to_string(),
229 ));
230 }
231
232 let mut seen_prefixes = HashSet::<String>::new();
233 let mut enabled_count = 0usize;
234
235 for (index, repo) in self.repos.iter().enumerate() {
236 if !repo.is_enabled() {
237 continue;
238 }
239
240 enabled_count = enabled_count.saturating_add(1);
241
242 if repo.path.trim().is_empty() {
243 return Err(BvrError::InvalidArgument(format!(
244 "workspace repo[{index}] has an empty path"
245 )));
246 }
247
248 let prefix = repo.effective_prefix().to_ascii_lowercase();
249 if !seen_prefixes.insert(prefix.clone()) {
250 return Err(BvrError::InvalidArgument(format!(
251 "workspace repo[{index}] has duplicate prefix '{prefix}'"
252 )));
253 }
254 }
255
256 if enabled_count == 0 {
257 return Err(BvrError::InvalidArgument(
258 "workspace has no enabled repositories".to_string(),
259 ));
260 }
261
262 Ok(())
263 }
264}
265
266pub fn resolve_workspace_root(config_path: &Path) -> PathBuf {
267 config_path.parent().and_then(Path::parent).map_or_else(
268 || {
269 config_path
270 .parent()
271 .unwrap_or_else(|| Path::new("."))
272 .to_path_buf()
273 },
274 PathBuf::from,
275 )
276}
277
278fn normalize_path_for_display(path: &Path) -> String {
279 path.to_string_lossy().replace('\\', "/")
280}
281
282fn normalize_path_for_identity(path: &Path) -> PathBuf {
283 let mut normalized = PathBuf::new();
284 for component in path.components() {
285 match component {
286 Component::CurDir => {}
287 Component::ParentDir => {
288 normalized.pop();
289 }
290 Component::Normal(segment) => normalized.push(segment),
291 Component::RootDir | Component::Prefix(_) => {
292 normalized.push(component.as_os_str());
293 }
294 }
295 }
296 normalized
297}
298
299fn relative_path_matches_pattern(relative_path: &Path, pattern: &str) -> bool {
300 if relative_path.as_os_str().is_empty() {
301 return pattern.trim().is_empty() || pattern == ".";
302 }
303
304 let path_segments = relative_path
305 .components()
306 .map(|component| component.as_os_str().to_string_lossy().to_string())
307 .collect::<Vec<_>>();
308 let pattern_segments = pattern
309 .split('/')
310 .filter(|segment| !segment.is_empty())
311 .collect::<Vec<_>>();
312
313 if path_segments.len() != pattern_segments.len() {
314 return false;
315 }
316
317 pattern_segments
318 .iter()
319 .zip(&path_segments)
320 .all(|(pattern_segment, path_segment)| {
321 *pattern_segment == "*" || *pattern_segment == path_segment
322 })
323}
324
325fn is_excluded_workspace_path(relative_path: &Path, exclude_patterns: &[String]) -> bool {
326 let components = relative_path
327 .components()
328 .map(|component| component.as_os_str().to_string_lossy().to_string())
329 .collect::<Vec<_>>();
330
331 exclude_patterns.iter().any(|pattern| {
332 if pattern.contains('/') || pattern.contains('*') {
333 relative_path_matches_pattern(relative_path, pattern)
334 } else {
335 components.iter().any(|component| component == pattern)
336 }
337 })
338}
339
340fn repo_identity_key(repo_path: &Path, workspace_root: &Path) -> String {
341 let resolved = if repo_path.is_absolute() {
342 repo_path.to_path_buf()
343 } else {
344 workspace_root.join(repo_path)
345 };
346 let normalized =
347 std::fs::canonicalize(&resolved).unwrap_or_else(|_| normalize_path_for_identity(&resolved));
348 normalize_path_for_display(&normalized)
349}
350
351fn workspace_root_repo_name(workspace_root: &Path) -> String {
352 workspace_root
353 .file_name()
354 .and_then(OsStr::to_str)
355 .unwrap_or("root")
356 .to_string()
357}
358
359fn inferred_repo_name(repo_path: &Path, workspace_root: &Path) -> String {
360 let resolved = if repo_path.is_absolute() {
361 repo_path.to_path_buf()
362 } else {
363 workspace_root.join(repo_path)
364 };
365 let normalized =
366 std::fs::canonicalize(&resolved).unwrap_or_else(|_| normalize_path_for_identity(&resolved));
367 normalized.file_name().and_then(OsStr::to_str).map_or_else(
368 || workspace_root_repo_name(workspace_root),
369 ToString::to_string,
370 )
371}
372
373fn discover_workspace_repos(
374 workspace_root: &Path,
375 discovery: &WorkspaceDiscoveryConfig,
376 defaults: &WorkspaceDefaultsConfig,
377 explicit_repos: &[WorkspaceRepoConfig],
378) -> Result<Vec<WorkspaceRepoConfig>> {
379 let mut discovered = Vec::<WorkspaceRepoConfig>::new();
380 let mut seen_repo_paths = explicit_repos
381 .iter()
382 .filter(|repo| repo.is_enabled())
383 .map(|repo| repo_identity_key(Path::new(repo.path.trim()), workspace_root))
384 .collect::<HashSet<_>>();
385
386 if discovery
387 .patterns
388 .iter()
389 .any(|pattern| relative_path_matches_pattern(Path::new(""), pattern))
390 {
391 let identity = repo_identity_key(Path::new("."), workspace_root);
392 let beads_dir = workspace_root
393 .join(WorkspaceRepoConfig::default().effective_beads_path(Some(defaults)));
394 if seen_repo_paths.insert(identity) && beads_dir.is_dir() {
395 discovered.push(WorkspaceRepoConfig {
396 name: workspace_root_repo_name(workspace_root),
397 path: ".".to_string(),
398 ..WorkspaceRepoConfig::default()
399 });
400 }
401 }
402
403 let mut stack = vec![(workspace_root.to_path_buf(), 0usize)];
404
405 while let Some((current_dir, depth)) = stack.pop() {
406 if depth >= discovery.max_depth {
407 continue;
408 }
409
410 let mut child_dirs = Vec::<PathBuf>::new();
411 for entry in std::fs::read_dir(¤t_dir)? {
412 let entry = entry?;
413 let path = entry.path();
414 if path.is_dir() {
415 child_dirs.push(path);
416 }
417 }
418 child_dirs.sort();
419
420 for child_dir in child_dirs {
421 let relative = child_dir
422 .strip_prefix(workspace_root)
423 .unwrap_or(child_dir.as_path());
424 if is_excluded_workspace_path(relative, &discovery.exclude) {
425 continue;
426 }
427
428 let next_depth = depth.saturating_add(1);
429 if next_depth <= discovery.max_depth {
430 stack.push((child_dir.clone(), next_depth));
431 }
432
433 if !discovery
434 .patterns
435 .iter()
436 .any(|pattern| relative_path_matches_pattern(relative, pattern))
437 {
438 continue;
439 }
440
441 let identity = repo_identity_key(relative, workspace_root);
442 if !seen_repo_paths.insert(identity) {
443 continue;
444 }
445
446 let beads_dir =
447 child_dir.join(WorkspaceRepoConfig::default().effective_beads_path(Some(defaults)));
448 if !beads_dir.is_dir() {
449 continue;
450 }
451
452 discovered.push(WorkspaceRepoConfig {
453 path: normalize_path_for_display(relative),
454 ..WorkspaceRepoConfig::default()
455 });
456 }
457 }
458
459 discovered.sort_by(|left, right| left.path.cmp(&right.path));
460 Ok(discovered)
461}
462
463fn qualify_id(local_id: &str, prefix: &str) -> String {
464 if local_id
465 .to_ascii_lowercase()
466 .starts_with(&prefix.to_ascii_lowercase())
467 {
468 local_id.to_string()
469 } else {
470 format!("{prefix}{local_id}")
471 }
472}
473
474fn has_known_prefix(id: &str, prefixes: &[String]) -> bool {
475 let id_lower = id.to_ascii_lowercase();
476 prefixes
477 .iter()
478 .any(|prefix| id_lower.starts_with(&prefix.to_ascii_lowercase()))
479}
480
481pub fn namespace_workspace_issues(
482 issues: &mut [Issue],
483 prefix: &str,
484 repo_name: &str,
485 known_prefixes: &[String],
486) {
487 let local_ids = issues
488 .iter()
489 .map(|issue| issue.id.trim().to_string())
490 .collect::<HashSet<_>>();
491
492 for issue in issues.iter_mut() {
493 let local_issue_id = issue.id.trim().to_string();
494 issue.id = qualify_id(&local_issue_id, prefix);
495 issue.source_repo = repo_name.to_string();
496 issue.workspace_prefix = Some(prefix.trim().to_string());
497
498 for dependency in &mut issue.dependencies {
499 let dep_issue_id = dependency.issue_id.trim();
500 dependency.issue_id = if dep_issue_id.is_empty() {
501 issue.id.clone()
502 } else {
503 qualify_id(dep_issue_id, prefix)
504 };
505
506 let depends_on = dependency.depends_on_id.trim();
507 dependency.depends_on_id = if depends_on.is_empty() {
508 depends_on.to_string()
509 } else if local_ids.contains(depends_on) {
510 qualify_id(depends_on, prefix)
511 } else if has_known_prefix(depends_on, known_prefixes) {
512 depends_on.to_string()
513 } else {
514 qualify_id(depends_on, prefix)
515 };
516 }
517
518 for comment in &mut issue.comments {
519 let comment_issue_id = comment.issue_id.trim();
520 comment.issue_id = if comment_issue_id.is_empty() {
521 issue.id.clone()
522 } else {
523 qualify_id(comment_issue_id, prefix)
524 };
525 }
526 }
527}
528
529fn find_beads_dir_from(start: &Path) -> Option<PathBuf> {
530 for ancestor in start.ancestors() {
531 let candidate = ancestor.join(".beads");
532 if candidate.is_dir() {
533 return Some(candidate);
534 }
535 }
536
537 None
538}
539
540fn resolve_gitdir_pointer(git_file: &Path) -> Option<PathBuf> {
541 let contents = std::fs::read_to_string(git_file).ok()?;
542 let raw = contents.strip_prefix("gitdir:")?.trim();
543 if raw.is_empty() {
544 return None;
545 }
546
547 let pointed = PathBuf::from(raw);
548 if pointed.is_absolute() {
549 Some(pointed)
550 } else {
551 git_file.parent().map(|parent| parent.join(pointed))
552 }
553}
554
555fn resolve_worktree_common_dir(gitdir: &Path) -> Option<PathBuf> {
556 let commondir_path = gitdir.join("commondir");
557 let raw = std::fs::read_to_string(commondir_path).ok()?;
558 let trimmed = raw.trim();
559 if trimmed.is_empty() {
560 return None;
561 }
562
563 let common_dir = PathBuf::from(trimmed);
564 if common_dir.is_absolute() {
565 std::fs::canonicalize(common_dir).ok()
566 } else {
567 std::fs::canonicalize(gitdir.join(common_dir)).ok()
568 }
569}
570
571fn find_worktree_main_repo_beads_dir_from(start: &Path) -> Option<PathBuf> {
572 for ancestor in start.ancestors() {
573 let git_file = ancestor.join(".git");
574 if !git_file.is_file() {
575 continue;
576 }
577
578 let Some(gitdir) = resolve_gitdir_pointer(&git_file) else {
579 continue;
580 };
581 let Some(common_dir) = resolve_worktree_common_dir(&gitdir) else {
582 continue;
583 };
584 let Some(repo_root) = common_dir.parent() else {
585 continue;
586 };
587 let beads_dir = repo_root.join(".beads");
588 if beads_dir.is_dir() {
589 return Some(beads_dir);
590 }
591 }
592
593 None
594}
595
596pub fn find_workspace_config_from(start: &Path) -> Option<PathBuf> {
597 for ancestor in start.ancestors() {
598 let candidate = ancestor.join(WORKSPACE_CONFIG_PATH);
599 if candidate.is_file() {
600 return Some(candidate);
601 }
602 }
603
604 None
605}
606
607pub fn get_beads_dir(repo_path: Option<&Path>) -> Result<PathBuf> {
608 if let Ok(dir) = std::env::var(BEADS_DIR_ENV)
609 && !dir.trim().is_empty()
610 {
611 let candidate = PathBuf::from(dir);
612 if candidate.is_dir() {
613 return Ok(candidate);
614 }
615
616 return Err(BvrError::MissingBeadsDir(candidate));
617 }
618
619 let root = if let Some(path) = repo_path {
620 path.to_path_buf()
621 } else {
622 std::env::current_dir()?
623 };
624
625 if let Some(beads_dir) = find_beads_dir_from(&root) {
626 return Ok(beads_dir);
627 }
628
629 if let Some(beads_dir) = find_worktree_main_repo_beads_dir_from(&root) {
630 return Ok(beads_dir);
631 }
632
633 Err(BvrError::MissingBeadsDir(root.join(".beads")))
634}
635
636pub fn find_jsonl_path(beads_dir: &Path) -> Result<PathBuf> {
637 let mut found_preferred: Option<PathBuf> = None;
638 let mut other_preferred = Vec::<&str>::new();
639
640 for preferred in PREFERRED_JSONL_NAMES {
641 let path = beads_dir.join(preferred);
642 if path.is_file() && std::fs::metadata(&path).is_ok_and(|meta| meta.len() > 0) {
643 if found_preferred.is_none() {
644 found_preferred = Some(path);
645 } else {
646 other_preferred.push(preferred);
647 }
648 }
649 }
650
651 if let Some(ref chosen) = found_preferred {
652 if !other_preferred.is_empty() {
653 tracing::warn!(
654 "multiple issue files found in {}: using {}, ignoring {}",
655 beads_dir.display(),
656 chosen.file_name().unwrap_or_default().to_string_lossy(),
657 other_preferred.join(", ")
658 );
659 }
660 return Ok(chosen.clone());
661 }
662
663 let mut fallback_candidates = Vec::<PathBuf>::new();
664 for entry in std::fs::read_dir(beads_dir)? {
665 let entry = entry?;
666 let path = entry.path();
667 if !path.is_file() {
668 continue;
669 }
670 if path.extension() != Some(OsStr::new("jsonl")) {
671 continue;
672 }
673 if std::fs::metadata(&path).is_ok_and(|meta| meta.len() == 0) {
674 continue;
675 }
676
677 let file_name = path
678 .file_name()
679 .and_then(OsStr::to_str)
680 .unwrap_or_default()
681 .to_ascii_lowercase();
682
683 let skip = file_name.contains(".backup")
684 || file_name.contains(".orig")
685 || file_name.contains(".merge")
686 || file_name == "deletions.jsonl"
687 || file_name.starts_with("beads.left")
688 || file_name.starts_with("beads.right");
689
690 if skip {
691 continue;
692 }
693
694 fallback_candidates.push(path);
695 }
696
697 fallback_candidates.sort();
698 fallback_candidates
699 .into_iter()
700 .next()
701 .ok_or_else(|| BvrError::MissingBeadsFile(beads_dir.to_path_buf()))
702}
703
704pub fn load_issues(repo_path: Option<&Path>) -> Result<Vec<Issue>> {
705 let beads_dir = get_beads_dir(repo_path)?;
706 let path = find_jsonl_path(&beads_dir)?;
707 load_issues_from_file(&path)
708}
709
710pub fn load_workspace_config(path: &Path) -> Result<WorkspaceConfig> {
711 let config_text = std::fs::read_to_string(path)?;
712 let mut config = serde_yaml::from_str::<WorkspaceConfig>(&config_text).map_err(|error| {
713 BvrError::InvalidArgument(format!(
714 "invalid workspace config {}: {error}",
715 path.display()
716 ))
717 })?;
718
719 config.apply_defaults();
720 let workspace_root = resolve_workspace_root(path);
721 config.repos = config.resolve_repos(&workspace_root, path)?;
722 config.validate()?;
723 Ok(config)
724}
725
726pub fn load_workspace_issues(path: &Path) -> Result<Vec<Issue>> {
727 let (issues, _) = load_workspace_issues_with_summary(path)?;
728 Ok(issues)
729}
730
731pub fn find_workspace_issue_paths(path: &Path) -> Result<Vec<PathBuf>> {
732 let config = load_workspace_config(path)?;
733 let workspace_root = resolve_workspace_root(path);
734 let mut paths = Vec::<PathBuf>::new();
735
736 for repo in config.repos.iter().filter(|repo| repo.is_enabled()) {
737 let repo_name = repo.effective_name();
738 let repo_path = if Path::new(repo.path.trim()).is_absolute() {
739 PathBuf::from(repo.path.trim())
740 } else {
741 let joined = workspace_root.join(repo.path.trim());
742 std::fs::canonicalize(&joined).unwrap_or_else(|_| normalize_path_for_identity(&joined))
743 };
744 let beads_dir = repo_path.join(repo.effective_beads_path(Some(&config.defaults)));
745
746 match find_jsonl_path(&beads_dir) {
747 Ok(jsonl_path) => paths.push(jsonl_path),
748 Err(error) => warn(format!(
749 "workspace repo '{repo_name}' watch source unavailable: {error}"
750 )),
751 }
752 }
753
754 if paths.is_empty() {
755 return Err(BvrError::InvalidArgument(format!(
756 "workspace has no readable issues.jsonl sources: {}",
757 path.display()
758 )));
759 }
760
761 Ok(paths)
762}
763
764pub fn load_workspace_issues_with_summary(
765 path: &Path,
766) -> Result<(Vec<Issue>, WorkspaceLoadSummary)> {
767 let config = load_workspace_config(path)?;
768 let workspace_root = resolve_workspace_root(path);
769
770 let enabled_repos = config
771 .repos
772 .iter()
773 .filter(|repo| repo.is_enabled())
774 .cloned()
775 .collect::<Vec<_>>();
776
777 let known_prefixes = enabled_repos
778 .iter()
779 .map(WorkspaceRepoConfig::effective_prefix)
780 .collect::<Vec<_>>();
781
782 let mut per_repo_results = Vec::<WorkspaceRepoLoadResult>::new();
783
784 for repo in &enabled_repos {
785 let repo_name = repo.effective_name();
786 let prefix = repo.effective_prefix();
787
788 let repo_path = if Path::new(repo.path.trim()).is_absolute() {
789 PathBuf::from(repo.path.trim())
790 } else {
791 let joined = workspace_root.join(repo.path.trim());
792 std::fs::canonicalize(&joined).unwrap_or_else(|_| normalize_path_for_identity(&joined))
793 };
794 let beads_dir = repo_path.join(repo.effective_beads_path(Some(&config.defaults)));
795
796 let repo_result = (|| -> Result<Vec<Issue>> {
797 let jsonl_path = find_jsonl_path(&beads_dir)?;
798 let mut issues = load_issues_from_file(&jsonl_path)?;
799 namespace_workspace_issues(&mut issues, &prefix, &repo_name, &known_prefixes);
800 Ok(issues)
801 })();
802
803 match repo_result {
804 Ok(issues) => {
805 per_repo_results.push(WorkspaceRepoLoadResult {
806 repo_name,
807 prefix,
808 issues,
809 error: None,
810 });
811 }
812 Err(error) => {
813 warn(format!(
814 "workspace repo '{repo_name}' failed to load: {error}"
815 ));
816 per_repo_results.push(WorkspaceRepoLoadResult {
817 repo_name,
818 prefix,
819 issues: Vec::new(),
820 error: Some(error.to_string()),
821 });
822 }
823 }
824 }
825
826 let mut issues = Vec::<Issue>::new();
827 let mut summary = WorkspaceLoadSummary {
828 total_repos: per_repo_results.len(),
829 ..WorkspaceLoadSummary::default()
830 };
831
832 for result in per_repo_results {
833 if result.error.is_some() {
834 summary.failed_repos = summary.failed_repos.saturating_add(1);
835 summary.failed_repo_names.push(result.repo_name);
836 continue;
837 }
838
839 summary.successful_repos = summary.successful_repos.saturating_add(1);
840 if !result.prefix.trim().is_empty() {
841 summary.repo_prefixes.push(result.prefix);
842 }
843 summary.total_issues = summary.total_issues.saturating_add(result.issues.len());
844 issues.extend(result.issues);
845 }
846
847 if summary.successful_repos == 0 && summary.failed_repos > 0 {
848 return Err(BvrError::InvalidArgument(format!(
849 "workspace load failed for all repositories: {}",
850 summary.failed_repo_names.join(", ")
851 )));
852 }
853
854 Ok((issues, summary))
855}
856
857fn deduplicate_issues(issues: Vec<Issue>) -> Vec<Issue> {
860 let mut seen: HashMap<String, usize> = HashMap::with_capacity(issues.len());
861 let mut result = Vec::with_capacity(issues.len());
862 for issue in issues {
863 if let Some(&prev_idx) = seen.get(&issue.id) {
864 warn(format!(
865 "duplicate issue ID '{}': keeping later occurrence",
866 issue.id
867 ));
868 let id = issue.id.clone();
869 result[prev_idx] = issue;
870 seen.insert(id, prev_idx);
871 } else {
872 seen.insert(issue.id.clone(), result.len());
873 result.push(issue);
874 }
875 }
876 result
877}
878
879pub fn load_issues_from_file(path: &Path) -> Result<Vec<Issue>> {
880 let file = File::open(path)?;
881 let mut reader = BufReader::new(file);
882 let mut issues = Vec::new();
883
884 let mut line_no = 0usize;
885 let mut line = String::new();
886
887 loop {
888 line.clear();
889 let bytes = reader.read_line(&mut line)?;
890 if bytes == 0 {
891 break;
892 }
893 line_no += 1;
894
895 if bytes > MAX_LINE_BYTES {
896 warn(format!(
897 "skipping line {line_no} in {}: line exceeds {MAX_LINE_BYTES} bytes",
898 path.display()
899 ));
900 continue;
901 }
902
903 let trimmed = if line_no == 1 {
904 line.trim_start_matches('\u{feff}').trim()
905 } else {
906 line.trim()
907 };
908
909 if trimmed.is_empty() {
910 continue;
911 }
912
913 match serde_json::from_str::<Issue>(trimmed) {
914 Ok(mut issue) => {
915 issue.status = issue.normalized_status();
916 if let Err(error) = issue.validate() {
917 warn(format!(
918 "skipping invalid issue on line {line_no} in {}: {error}",
919 path.display()
920 ));
921 continue;
922 }
923 issues.push(issue);
924 }
925 Err(error) => {
926 warn(format!(
927 "skipping malformed JSON on line {line_no} in {}: {error}",
928 path.display()
929 ));
930 }
931 }
932 }
933
934 Ok(deduplicate_issues(issues))
935}
936
937pub fn parse_issues_from_text(text: &str) -> Result<Vec<Issue>> {
939 let mut issues = Vec::new();
940 for (line_no, raw_line) in text.lines().enumerate() {
941 let trimmed = if line_no == 0 {
942 raw_line.trim_start_matches('\u{feff}').trim()
943 } else {
944 raw_line.trim()
945 };
946 if trimmed.is_empty() {
947 continue;
948 }
949 match serde_json::from_str::<Issue>(trimmed) {
950 Ok(mut issue) => {
951 issue.status = issue.normalized_status();
952 if let Err(error) = issue.validate() {
953 warn(format!(
954 "skipping invalid issue on line {}: {error}",
955 line_no + 1
956 ));
957 continue;
958 }
959 issues.push(issue);
960 }
961 Err(error) => {
962 warn(format!(
963 "skipping malformed JSON on line {}: {error}",
964 line_no + 1
965 ));
966 }
967 }
968 }
969 Ok(deduplicate_issues(issues))
970}
971
972pub fn load_sprints(repo_path: Option<&Path>) -> Result<Vec<Sprint>> {
973 let beads_dir = get_beads_dir(repo_path)?;
974 let path = beads_dir.join(SPRINTS_FILE_NAME);
975 load_sprints_from_file(&path)
976}
977
978pub fn load_sprints_from_file(path: &Path) -> Result<Vec<Sprint>> {
979 if !path.exists() {
980 return Ok(Vec::new());
981 }
982
983 let file = File::open(path)?;
984 let mut reader = BufReader::new(file);
985 let mut sprints = Vec::new();
986
987 let mut line_no = 0usize;
988 let mut line = String::new();
989
990 loop {
991 line.clear();
992 let bytes = reader.read_line(&mut line)?;
993 if bytes == 0 {
994 break;
995 }
996 line_no += 1;
997
998 if bytes > MAX_LINE_BYTES {
999 warn(format!(
1000 "skipping line {line_no} in {}: line exceeds {MAX_LINE_BYTES} bytes",
1001 path.display()
1002 ));
1003 continue;
1004 }
1005
1006 let trimmed = if line_no == 1 {
1007 line.trim_start_matches('\u{feff}').trim()
1008 } else {
1009 line.trim()
1010 };
1011 if trimmed.is_empty() {
1012 continue;
1013 }
1014
1015 match serde_json::from_str::<Sprint>(trimmed) {
1016 Ok(sprint) => {
1017 if sprint.id.trim().is_empty() || sprint.name.trim().is_empty() {
1018 warn(format!(
1019 "skipping invalid sprint on line {line_no} in {}: missing id or name",
1020 path.display()
1021 ));
1022 continue;
1023 }
1024 if sprint
1025 .start_date
1026 .zip(sprint.end_date)
1027 .is_some_and(|(start, end)| end < start)
1028 {
1029 warn(format!(
1030 "skipping invalid sprint on line {line_no} in {}: end_date before start_date",
1031 path.display()
1032 ));
1033 continue;
1034 }
1035 sprints.push(sprint);
1036 }
1037 Err(error) => {
1038 warn(format!(
1039 "skipping malformed sprint JSON on line {line_no} in {}: {error}",
1040 path.display()
1041 ));
1042 }
1043 }
1044 }
1045
1046 Ok(sprints)
1047}
1048
1049fn warn(message: String) {
1050 if !is_robot_mode() {
1051 eprintln!("Warning: {message}");
1052 }
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057 use std::io::Write;
1058
1059 use super::*;
1060
1061 #[test]
1062 fn parses_minimal_jsonl() {
1063 let dir = tempfile::tempdir().expect("tempdir");
1064 let path = dir.path().join("issues.jsonl");
1065 let mut file = File::create(&path).expect("create file");
1066
1067 writeln!(
1068 file,
1069 "{{\"id\":\"A\",\"title\":\"Root\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}}"
1070 )
1071 .expect("write line A");
1072 writeln!(
1073 file,
1074 "{{\"id\":\"B\",\"title\":\"Child\",\"status\":\"blocked\",\"priority\":2,\"issue_type\":\"task\",\"dependencies\":[{{\"depends_on_id\":\"A\",\"type\":\"blocks\"}}]}}"
1075 )
1076 .expect("write line B");
1077
1078 let issues = load_issues_from_file(&path).expect("load issues");
1079 assert_eq!(issues.len(), 2);
1080 assert_eq!(issues[0].id, "A");
1081 assert_eq!(issues[1].dependencies.len(), 1);
1082 }
1083
1084 #[test]
1085 fn finds_preferred_file() {
1086 let dir = tempfile::tempdir().expect("tempdir");
1087 let beads_dir = dir.path();
1088 std::fs::write(beads_dir.join("issues.jsonl"), "{}\n").expect("write issues");
1089 std::fs::write(beads_dir.join("beads.jsonl"), "{}\n").expect("write beads");
1090
1091 let path = find_jsonl_path(beads_dir).expect("find path");
1092 assert!(path.ends_with("beads.jsonl"));
1093 }
1094
1095 #[test]
1096 fn finds_preferred_file_with_multiple_candidates() {
1097 let dir = tempfile::tempdir().expect("tempdir");
1098 let beads_dir = dir.path();
1099 std::fs::write(beads_dir.join("beads.jsonl"), "{}\n").expect("write beads");
1100 std::fs::write(beads_dir.join("issues.jsonl"), "{}\n").expect("write issues");
1101 std::fs::write(beads_dir.join("beads.base.jsonl"), "{}\n").expect("write base");
1102
1103 let path = find_jsonl_path(beads_dir).expect("find path");
1104 assert!(
1105 path.ends_with("beads.jsonl"),
1106 "expected beads.jsonl to win, got: {}",
1107 path.display()
1108 );
1109 }
1110
1111 #[test]
1112 fn get_beads_dir_finds_parent_directory() {
1113 let dir = tempfile::tempdir().expect("tempdir");
1114 let root = dir.path();
1115 std::fs::create_dir_all(root.join(".beads")).expect("create .beads");
1116 let nested = root.join("nested/work");
1117 std::fs::create_dir_all(&nested).expect("create nested");
1118
1119 let beads_dir = get_beads_dir(Some(&nested)).expect("find parent .beads");
1120 assert_eq!(beads_dir, root.join(".beads"));
1121 }
1122
1123 #[test]
1124 fn get_beads_dir_falls_back_to_main_repo_for_git_worktree() {
1125 let dir = tempfile::tempdir().expect("tempdir");
1126 let root = dir.path();
1127 let main_repo = root.join("main-repo");
1128 let worktree = root.join("worktree");
1129 let gitdir = main_repo.join(".git/worktrees/feature");
1130
1131 std::fs::create_dir_all(main_repo.join(".beads")).expect("create main .beads");
1132 std::fs::create_dir_all(&gitdir).expect("create worktree gitdir");
1133 std::fs::create_dir_all(worktree.join("nested/path")).expect("create nested worktree");
1134 std::fs::write(
1135 worktree.join(".git"),
1136 format!("gitdir: {}\n", gitdir.display()),
1137 )
1138 .expect("write worktree .git file");
1139 std::fs::write(gitdir.join("commondir"), "../../\n").expect("write commondir");
1140
1141 let beads_dir =
1142 get_beads_dir(Some(&worktree.join("nested/path"))).expect("find main repo .beads");
1143 assert_eq!(beads_dir, main_repo.join(".beads"));
1144 }
1145
1146 #[test]
1147 fn get_beads_dir_ignores_malformed_intermediate_git_file_during_worktree_fallback() {
1148 let dir = tempfile::tempdir().expect("tempdir");
1149 let root = dir.path();
1150 let main_repo = root.join("main-repo");
1151 let worktree = root.join("worktree");
1152 let gitdir = main_repo.join(".git/worktrees/feature");
1153 let nested = worktree.join("nested/path");
1154
1155 std::fs::create_dir_all(main_repo.join(".beads")).expect("create main .beads");
1156 std::fs::create_dir_all(&gitdir).expect("create worktree gitdir");
1157 std::fs::create_dir_all(&nested).expect("create nested worktree");
1158 std::fs::write(
1159 worktree.join(".git"),
1160 format!("gitdir: {}\n", gitdir.display()),
1161 )
1162 .expect("write worktree .git file");
1163 std::fs::write(gitdir.join("commondir"), "../../\n").expect("write commondir");
1164 std::fs::write(worktree.join("nested/.git"), "not a gitdir pointer\n")
1165 .expect("write malformed nested .git file");
1166
1167 let beads_dir = get_beads_dir(Some(&nested))
1168 .expect("skip malformed nested .git and find main repo .beads");
1169 assert_eq!(beads_dir, main_repo.join(".beads"));
1170 }
1171
1172 #[test]
1173 fn find_jsonl_fallback_is_deterministic() {
1174 let dir = tempfile::tempdir().expect("tempdir");
1175 let beads_dir = dir.path();
1176 std::fs::write(beads_dir.join("zeta.jsonl"), "{}\n").expect("write zeta");
1177 std::fs::write(beads_dir.join("alpha.jsonl"), "{}\n").expect("write alpha");
1178
1179 let path = find_jsonl_path(beads_dir).expect("find fallback path");
1180 assert!(path.ends_with("alpha.jsonl"));
1181 }
1182
1183 #[test]
1184 fn find_jsonl_fallback_skips_empty_files() {
1185 let dir = tempfile::tempdir().expect("tempdir");
1186 let beads_dir = dir.path();
1187 std::fs::write(beads_dir.join("alpha.jsonl"), "").expect("write empty alpha");
1188 std::fs::write(beads_dir.join("zeta.jsonl"), "{}\n").expect("write zeta");
1189
1190 let path = find_jsonl_path(beads_dir).expect("find fallback path");
1191 assert!(path.ends_with("zeta.jsonl"));
1192 }
1193
1194 #[test]
1195 fn find_workspace_issue_paths_collects_enabled_repo_sources() {
1196 let dir = tempfile::tempdir().expect("tempdir");
1197 let root = dir.path();
1198 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1199 std::fs::create_dir_all(root.join("services/api/.beads")).expect("create api beads");
1200 std::fs::create_dir_all(root.join("apps/web/.beads")).expect("create web beads");
1201 std::fs::write(
1202 root.join(".bv/workspace.yaml"),
1203 concat!(
1204 "repos:\n",
1205 " - name: api\n",
1206 " path: services/api\n",
1207 " - name: web\n",
1208 " path: apps/web\n",
1209 ),
1210 )
1211 .expect("write workspace config");
1212 std::fs::write(root.join("services/api/.beads/issues.jsonl"), "{}\n")
1213 .expect("write api issues");
1214 std::fs::write(root.join("apps/web/.beads/issues.jsonl"), "{}\n").expect("write web");
1215
1216 let mut paths =
1217 find_workspace_issue_paths(&root.join(".bv/workspace.yaml")).expect("watch paths");
1218 paths.sort();
1219
1220 assert_eq!(paths.len(), 2);
1221 assert!(paths[0].ends_with("apps/web/.beads/issues.jsonl"));
1222 assert!(paths[1].ends_with("services/api/.beads/issues.jsonl"));
1223 }
1224
1225 #[test]
1226 fn load_sprints_uses_nested_repo_path() {
1227 let dir = tempfile::tempdir().expect("tempdir");
1228 let root = dir.path();
1229 let beads_dir = root.join(".beads");
1230 let nested = root.join("nested/work");
1231 std::fs::create_dir_all(&beads_dir).expect("create .beads");
1232 std::fs::create_dir_all(&nested).expect("create nested");
1233 std::fs::write(
1234 beads_dir.join("sprints.jsonl"),
1235 "{\"id\":\"s1\",\"name\":\"Sprint 1\",\"bead_ids\":[\"A\"]}\n",
1236 )
1237 .expect("write sprints");
1238
1239 let sprints = load_sprints(Some(&nested)).expect("load sprints");
1240 assert_eq!(sprints.len(), 1);
1241 assert_eq!(sprints[0].id, "s1");
1242 }
1243
1244 #[test]
1245 fn find_workspace_config_walks_up_directory_tree() {
1246 let dir = tempfile::tempdir().expect("tempdir");
1247 let root = dir.path();
1248 let workspace_dir = root.join(".bv");
1249 std::fs::create_dir_all(&workspace_dir).expect("create .bv");
1250 let config_path = workspace_dir.join("workspace.yaml");
1251 std::fs::write(&config_path, "repos:\n - path: api\n").expect("write workspace config");
1252
1253 let nested = root.join("services/api/src");
1254 std::fs::create_dir_all(&nested).expect("create nested path");
1255
1256 let found = find_workspace_config_from(&nested).expect("find workspace config");
1257 assert_eq!(found, config_path);
1258 }
1259
1260 #[test]
1261 fn load_workspace_config_applies_discovery_defaults() {
1262 let dir = tempfile::tempdir().expect("tempdir");
1263 let root = dir.path();
1264 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1265 std::fs::create_dir_all(root.join("apps/web/.beads")).expect("create web beads");
1266 std::fs::write(
1267 root.join(".bv/workspace.yaml"),
1268 "discovery:\n enabled: true\n",
1269 )
1270 .expect("write workspace config");
1271 std::fs::write(
1272 root.join("apps/web/.beads/issues.jsonl"),
1273 "{\"id\":\"UI-1\",\"title\":\"UI\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1274 )
1275 .expect("write web issues");
1276
1277 let config =
1278 load_workspace_config(&root.join(".bv/workspace.yaml")).expect("load workspace config");
1279
1280 assert!(config.discovery.enabled);
1281 assert!(
1282 config
1283 .discovery
1284 .patterns
1285 .iter()
1286 .any(|pattern| pattern == "packages/*")
1287 );
1288 assert!(
1289 config
1290 .discovery
1291 .exclude
1292 .iter()
1293 .any(|pattern| pattern == "node_modules")
1294 );
1295 assert_eq!(config.discovery.max_depth, 2);
1296 assert_eq!(config.repos.len(), 1);
1297 assert_eq!(config.repos[0].path, "apps/web");
1298 assert_eq!(config.repos[0].effective_prefix(), "web-");
1299 }
1300
1301 #[test]
1302 fn load_workspace_config_reports_empty_discovery_with_guidance() {
1303 let dir = tempfile::tempdir().expect("tempdir");
1304 let root = dir.path();
1305 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1306 let config_path = root.join(".bv/workspace.yaml");
1307 std::fs::write(&config_path, "discovery:\n enabled: true\n").expect("write config");
1308
1309 let error = load_workspace_config(&config_path).expect_err("missing discovery repos");
1310 let message = error.to_string();
1311 assert!(message.contains("workspace discovery found no repositories"));
1312 assert!(message.contains("Patterns: ["));
1313 assert!(message.contains("defaults.beads_path"));
1314 }
1315
1316 #[test]
1317 fn load_workspace_issues_discovers_repos_from_common_layouts() {
1318 let dir = tempfile::tempdir().expect("tempdir");
1319 let root = dir.path();
1320 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1321 std::fs::create_dir_all(root.join("services/api/.beads")).expect("create api .beads");
1322 std::fs::create_dir_all(root.join("apps/web/.beads")).expect("create web .beads");
1323 std::fs::write(
1324 root.join(".bv/workspace.yaml"),
1325 "discovery:\n enabled: true\n",
1326 )
1327 .expect("write workspace config");
1328 std::fs::write(
1329 root.join("services/api/.beads/issues.jsonl"),
1330 "{\"id\":\"AUTH-1\",\"title\":\"API Auth\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1331 )
1332 .expect("write api issues");
1333 std::fs::write(
1334 root.join("apps/web/.beads/issues.jsonl"),
1335 "{\"id\":\"UI-1\",\"title\":\"Web UI\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1336 )
1337 .expect("write web issues");
1338
1339 let (issues, summary) =
1340 load_workspace_issues_with_summary(&root.join(".bv/workspace.yaml"))
1341 .expect("load workspace issues");
1342
1343 assert_eq!(summary.total_repos, 2);
1344 assert_eq!(summary.successful_repos, 2);
1345 assert_eq!(issues.len(), 2);
1346 assert!(issues.iter().any(|issue| issue.id == "api-AUTH-1"));
1347 assert!(issues.iter().any(|issue| issue.id == "web-UI-1"));
1348 }
1349
1350 #[test]
1351 fn load_workspace_issues_discovers_workspace_root_repo_when_pattern_matches_dot() {
1352 let dir = tempfile::tempdir().expect("tempdir");
1353 let root = dir.path();
1354 let root_name = workspace_root_repo_name(root);
1355 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1356 std::fs::create_dir_all(root.join(".beads")).expect("create root .beads");
1357 std::fs::write(
1358 root.join(".bv/workspace.yaml"),
1359 "discovery:\n enabled: true\n patterns: ['.']\n",
1360 )
1361 .expect("write workspace config");
1362 std::fs::write(
1363 root.join(".beads/issues.jsonl"),
1364 "{\"id\":\"ROOT-1\",\"title\":\"Workspace Root\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1365 )
1366 .expect("write root issues");
1367
1368 let config =
1369 load_workspace_config(&root.join(".bv/workspace.yaml")).expect("load workspace config");
1370 assert_eq!(config.repos.len(), 1);
1371 assert_eq!(config.repos[0].path, ".");
1372
1373 let (issues, summary) =
1374 load_workspace_issues_with_summary(&root.join(".bv/workspace.yaml"))
1375 .expect("load workspace issues");
1376
1377 assert_eq!(summary.total_repos, 1);
1378 assert_eq!(summary.successful_repos, 1);
1379 assert_eq!(issues.len(), 1);
1380 assert_eq!(issues[0].source_repo, root_name);
1381 assert_eq!(
1382 issues[0].id,
1383 format!("{}-ROOT-1", root_name.to_ascii_lowercase())
1384 );
1385 }
1386
1387 #[test]
1388 fn load_workspace_issues_namespaces_explicit_workspace_root_repo_path() {
1389 let dir = tempfile::tempdir().expect("tempdir");
1390 let root = dir.path();
1391 let root_name = workspace_root_repo_name(root);
1392 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1393 std::fs::create_dir_all(root.join(".beads")).expect("create root .beads");
1394 std::fs::write(root.join(".bv/workspace.yaml"), "repos:\n - path: ./\n")
1395 .expect("write workspace config");
1396 std::fs::write(
1397 root.join(".beads/issues.jsonl"),
1398 "{\"id\":\"ROOT-2\",\"title\":\"Explicit Root\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1399 )
1400 .expect("write root issues");
1401
1402 let config =
1403 load_workspace_config(&root.join(".bv/workspace.yaml")).expect("load workspace config");
1404 assert_eq!(config.repos.len(), 1);
1405 assert_eq!(config.repos[0].effective_name(), root_name);
1406 assert_eq!(
1407 config.repos[0].effective_prefix(),
1408 format!("{}-", root_name.to_ascii_lowercase())
1409 );
1410
1411 let (issues, summary) =
1412 load_workspace_issues_with_summary(&root.join(".bv/workspace.yaml"))
1413 .expect("load workspace issues");
1414
1415 assert_eq!(summary.total_repos, 1);
1416 assert_eq!(issues.len(), 1);
1417 assert_eq!(issues[0].source_repo, root_name);
1418 assert_eq!(
1419 issues[0].id,
1420 format!("{}-ROOT-2", issues[0].source_repo.to_ascii_lowercase())
1421 );
1422 }
1423
1424 #[test]
1425 fn load_workspace_issues_namespaces_explicit_parent_segment_repo_path() {
1426 let dir = tempfile::tempdir().expect("tempdir");
1427 let root = dir.path();
1428 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1429 std::fs::create_dir_all(root.join("services/.beads")).expect("create services .beads");
1430 std::fs::write(
1431 root.join(".bv/workspace.yaml"),
1432 "repos:\n - path: services/api/..\n",
1433 )
1434 .expect("write workspace config");
1435 std::fs::write(
1436 root.join("services/.beads/issues.jsonl"),
1437 "{\"id\":\"SRV-1\",\"title\":\"Service Root\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1438 )
1439 .expect("write services issues");
1440
1441 let config =
1442 load_workspace_config(&root.join(".bv/workspace.yaml")).expect("load workspace config");
1443 assert_eq!(config.repos.len(), 1);
1444 assert_eq!(config.repos[0].effective_name(), "services");
1445 assert_eq!(config.repos[0].effective_prefix(), "services-");
1446
1447 let (issues, summary) =
1448 load_workspace_issues_with_summary(&root.join(".bv/workspace.yaml"))
1449 .expect("load workspace issues");
1450
1451 assert_eq!(summary.total_repos, 1);
1452 assert_eq!(issues.len(), 1);
1453 assert_eq!(issues[0].source_repo, "services");
1454 assert_eq!(issues[0].id, "services-SRV-1");
1455 }
1456
1457 #[test]
1458 fn load_workspace_issues_applies_default_beads_path_to_explicit_and_discovered_repos() {
1459 let dir = tempfile::tempdir().expect("tempdir");
1460 let root = dir.path();
1461 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1462 std::fs::create_dir_all(root.join("services/api/trackers")).expect("create api trackers");
1463 std::fs::create_dir_all(root.join("apps/web/trackers")).expect("create web trackers");
1464 std::fs::write(
1465 root.join(".bv/workspace.yaml"),
1466 concat!(
1467 "defaults:\n",
1468 " beads_path: trackers\n",
1469 "discovery:\n",
1470 " enabled: true\n",
1471 "repos:\n",
1472 " - name: api\n",
1473 " path: services/api\n",
1474 ),
1475 )
1476 .expect("write workspace config");
1477 std::fs::write(
1478 root.join("services/api/trackers/issues.jsonl"),
1479 "{\"id\":\"AUTH-1\",\"title\":\"API Auth\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1480 )
1481 .expect("write api issues");
1482 std::fs::write(
1483 root.join("apps/web/trackers/issues.jsonl"),
1484 "{\"id\":\"UI-1\",\"title\":\"Web UI\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1485 )
1486 .expect("write web issues");
1487
1488 let mut paths =
1489 find_workspace_issue_paths(&root.join(".bv/workspace.yaml")).expect("watch paths");
1490 paths.sort();
1491 assert!(paths[0].ends_with("apps/web/trackers/issues.jsonl"));
1492 assert!(paths[1].ends_with("services/api/trackers/issues.jsonl"));
1493
1494 let (issues, summary) =
1495 load_workspace_issues_with_summary(&root.join(".bv/workspace.yaml"))
1496 .expect("load workspace issues");
1497 assert_eq!(summary.total_repos, 2);
1498 assert!(issues.iter().any(|issue| issue.id == "api-AUTH-1"));
1499 assert!(issues.iter().any(|issue| issue.id == "web-UI-1"));
1500 }
1501
1502 #[test]
1503 fn load_workspace_config_dedupes_explicit_repo_paths_with_dot_segments() {
1504 let dir = tempfile::tempdir().expect("tempdir");
1505 let root = dir.path();
1506 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
1507 std::fs::create_dir_all(root.join("services/api/.beads")).expect("create api .beads");
1508 std::fs::write(
1509 root.join(".bv/workspace.yaml"),
1510 concat!(
1511 "discovery:\n",
1512 " enabled: true\n",
1513 "repos:\n",
1514 " - name: backend\n",
1515 " path: services/./api\n",
1516 " prefix: backend-\n",
1517 ),
1518 )
1519 .expect("write workspace config");
1520 std::fs::write(
1521 root.join("services/api/.beads/issues.jsonl"),
1522 "{\"id\":\"AUTH-1\",\"title\":\"API Auth\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1523 )
1524 .expect("write api issues");
1525
1526 let config =
1527 load_workspace_config(&root.join(".bv/workspace.yaml")).expect("load workspace config");
1528
1529 assert_eq!(
1530 config.repos.len(),
1531 1,
1532 "same repo should not be rediscovered"
1533 );
1534 assert_eq!(config.repos[0].name, "backend");
1535 assert_eq!(config.repos[0].path, "services/./api");
1536 }
1537
1538 #[test]
1539 fn load_workspace_issues_namespaces_ids_and_dependencies() {
1540 let dir = tempfile::tempdir().expect("tempdir");
1541 let root = dir.path();
1542
1543 let workspace_dir = root.join(".bv");
1544 let api_beads = root.join("services/api/.beads");
1545 let web_beads = root.join("apps/web/.beads");
1546 std::fs::create_dir_all(&workspace_dir).expect("create .bv");
1547 std::fs::create_dir_all(&api_beads).expect("create api .beads");
1548 std::fs::create_dir_all(&web_beads).expect("create web .beads");
1549
1550 std::fs::write(
1551 workspace_dir.join("workspace.yaml"),
1552 "name: demo\nrepos:\n - name: api\n path: services/api\n prefix: api-\n - name: web\n path: apps/web\n prefix: web-\n",
1553 )
1554 .expect("write workspace config");
1555
1556 std::fs::write(
1557 api_beads.join("issues.jsonl"),
1558 "{\"id\":\"AUTH-1\",\"title\":\"Auth\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"dependencies\":[{\"issue_id\":\"AUTH-1\",\"depends_on_id\":\"AUTH-2\",\"type\":\"blocks\"}]}\n{\"id\":\"AUTH-2\",\"title\":\"Auth Prereq\",\"status\":\"open\",\"priority\":2,\"issue_type\":\"task\"}\n",
1559 )
1560 .expect("write api issues");
1561
1562 std::fs::write(
1563 web_beads.join("issues.jsonl"),
1564 "{\"id\":\"UI-1\",\"title\":\"UI\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\",\"dependencies\":[{\"issue_id\":\"UI-1\",\"depends_on_id\":\"api-AUTH-1\",\"type\":\"blocks\"}]}\n",
1565 )
1566 .expect("write web issues");
1567
1568 let (issues, summary) =
1569 load_workspace_issues_with_summary(&workspace_dir.join("workspace.yaml"))
1570 .expect("load workspace issues");
1571
1572 assert_eq!(summary.total_repos, 2);
1573 assert_eq!(summary.successful_repos, 2);
1574 assert_eq!(summary.failed_repos, 0);
1575 assert_eq!(summary.total_issues, 3);
1576
1577 let auth_issue = issues
1578 .iter()
1579 .find(|issue| issue.id == "api-AUTH-1")
1580 .expect("api-AUTH-1 issue");
1581 assert_eq!(auth_issue.source_repo, "api");
1582 assert_eq!(auth_issue.dependencies.len(), 1);
1583 assert_eq!(auth_issue.dependencies[0].depends_on_id, "api-AUTH-2");
1584
1585 let web_issue = issues
1586 .iter()
1587 .find(|issue| issue.id == "web-UI-1")
1588 .expect("web-UI-1 issue");
1589 assert_eq!(web_issue.source_repo, "web");
1590 assert_eq!(web_issue.dependencies[0].depends_on_id, "api-AUTH-1");
1591 }
1592
1593 #[test]
1594 fn load_workspace_issues_continues_when_some_repos_fail() {
1595 let dir = tempfile::tempdir().expect("tempdir");
1596 let root = dir.path();
1597
1598 let workspace_dir = root.join(".bv");
1599 let api_beads = root.join("services/api/.beads");
1600 std::fs::create_dir_all(&workspace_dir).expect("create .bv");
1601 std::fs::create_dir_all(&api_beads).expect("create api .beads");
1602
1603 std::fs::write(
1604 workspace_dir.join("workspace.yaml"),
1605 "repos:\n - name: api\n path: services/api\n prefix: api-\n - name: missing\n path: services/missing\n prefix: missing-\n",
1606 )
1607 .expect("write workspace config");
1608 std::fs::write(
1609 api_beads.join("issues.jsonl"),
1610 "{\"id\":\"AUTH-1\",\"title\":\"Auth\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1611 )
1612 .expect("write api issues");
1613
1614 let (issues, summary) =
1615 load_workspace_issues_with_summary(&workspace_dir.join("workspace.yaml"))
1616 .expect("load workspace issues");
1617
1618 assert_eq!(summary.total_repos, 2);
1619 assert_eq!(summary.successful_repos, 1);
1620 assert_eq!(summary.failed_repos, 1);
1621 assert_eq!(summary.failed_repo_names, vec!["missing".to_string()]);
1622 assert_eq!(issues.len(), 1);
1623 assert_eq!(issues[0].id, "api-AUTH-1");
1624 }
1625
1626 #[test]
1627 fn load_workspace_issues_errors_when_all_repos_fail() {
1628 let dir = tempfile::tempdir().expect("tempdir");
1629 let root = dir.path();
1630
1631 let workspace_dir = root.join(".bv");
1632 std::fs::create_dir_all(&workspace_dir).expect("create .bv");
1633
1634 std::fs::write(
1635 workspace_dir.join("workspace.yaml"),
1636 "repos:\n - name: missing-api\n path: services/api\n prefix: api-\n - name: missing-web\n path: apps/web\n prefix: web-\n",
1637 )
1638 .expect("write workspace config");
1639
1640 let error = load_workspace_issues_with_summary(&workspace_dir.join("workspace.yaml"))
1641 .expect_err("all repo failures should bubble up");
1642 let message = error.to_string();
1643
1644 assert!(message.contains("workspace load failed for all repositories"));
1645 assert!(message.contains("missing-api"));
1646 assert!(message.contains("missing-web"));
1647 }
1648
1649 #[test]
1652 fn qualify_id_adds_prefix_when_missing() {
1653 assert_eq!(qualify_id("AUTH-1", "api-"), "api-AUTH-1");
1654 }
1655
1656 #[test]
1657 fn qualify_id_skips_prefix_when_already_present() {
1658 assert_eq!(qualify_id("api-AUTH-1", "api-"), "api-AUTH-1");
1659 }
1660
1661 #[test]
1662 fn qualify_id_case_insensitive_prefix_check() {
1663 assert_eq!(qualify_id("API-AUTH-1", "api-"), "API-AUTH-1");
1664 }
1665
1666 #[test]
1669 fn has_known_prefix_matches() {
1670 let prefixes = vec!["api-".to_string(), "web-".to_string()];
1671 assert!(has_known_prefix("api-AUTH-1", &prefixes));
1672 assert!(has_known_prefix("web-UI-1", &prefixes));
1673 }
1674
1675 #[test]
1676 fn has_known_prefix_no_match() {
1677 let prefixes = vec!["api-".to_string()];
1678 assert!(!has_known_prefix("AUTH-1", &prefixes));
1679 }
1680
1681 #[test]
1682 fn has_known_prefix_case_insensitive() {
1683 let prefixes = vec!["API-".to_string()];
1684 assert!(has_known_prefix("api-AUTH-1", &prefixes));
1685 }
1686
1687 #[test]
1690 fn repo_config_is_enabled_default_true() {
1691 let repo = WorkspaceRepoConfig::default();
1692 assert!(repo.is_enabled());
1693 }
1694
1695 #[test]
1696 fn repo_config_is_enabled_explicit_false() {
1697 let repo = WorkspaceRepoConfig {
1698 enabled: Some(false),
1699 ..Default::default()
1700 };
1701 assert!(!repo.is_enabled());
1702 }
1703
1704 #[test]
1705 fn repo_config_effective_name_from_name_field() {
1706 let repo = WorkspaceRepoConfig {
1707 name: " my-repo ".to_string(),
1708 path: "/some/path/other".to_string(),
1709 ..Default::default()
1710 };
1711 assert_eq!(repo.effective_name(), "my-repo");
1712 }
1713
1714 #[test]
1715 fn repo_config_effective_name_from_path_when_name_empty() {
1716 let repo = WorkspaceRepoConfig {
1717 path: "/projects/my-app".to_string(),
1718 ..Default::default()
1719 };
1720 assert_eq!(repo.effective_name(), "my-app");
1721 }
1722
1723 #[test]
1724 fn repo_config_effective_prefix_from_prefix_field() {
1725 let repo = WorkspaceRepoConfig {
1726 prefix: " custom- ".to_string(),
1727 ..Default::default()
1728 };
1729 assert_eq!(repo.effective_prefix(), "custom-");
1730 }
1731
1732 #[test]
1733 fn repo_config_effective_prefix_generated_from_name() {
1734 let repo = WorkspaceRepoConfig {
1735 name: "MyApp".to_string(),
1736 ..Default::default()
1737 };
1738 assert_eq!(repo.effective_prefix(), "myapp-");
1739 }
1740
1741 #[test]
1742 fn repo_config_effective_beads_path_explicit() {
1743 let repo = WorkspaceRepoConfig {
1744 beads_path: " custom/path ".to_string(),
1745 ..Default::default()
1746 };
1747 assert_eq!(repo.effective_beads_path(None), "custom/path");
1748 }
1749
1750 #[test]
1751 fn repo_config_effective_beads_path_from_defaults() {
1752 let repo = WorkspaceRepoConfig::default();
1753 let defaults = WorkspaceDefaultsConfig {
1754 beads_path: "trackers".to_string(),
1755 };
1756 assert_eq!(repo.effective_beads_path(Some(&defaults)), "trackers");
1757 }
1758
1759 #[test]
1760 fn repo_config_effective_beads_path_fallback() {
1761 let repo = WorkspaceRepoConfig::default();
1762 assert_eq!(repo.effective_beads_path(None), ".beads");
1763 }
1764
1765 #[test]
1768 fn normalize_path_replaces_backslashes() {
1769 let path = Path::new("foo\\bar\\baz");
1770 assert_eq!(normalize_path_for_display(path), "foo/bar/baz");
1771 }
1772
1773 #[test]
1776 fn relative_path_matches_wildcard() {
1777 assert!(relative_path_matches_pattern(Path::new("apps"), "*"));
1778 }
1779
1780 #[test]
1781 fn relative_path_matches_nested_wildcard() {
1782 assert!(relative_path_matches_pattern(
1783 Path::new("packages/auth"),
1784 "packages/*"
1785 ));
1786 }
1787
1788 #[test]
1789 fn relative_path_no_match_wrong_depth() {
1790 assert!(!relative_path_matches_pattern(
1791 Path::new("packages/auth/src"),
1792 "packages/*"
1793 ));
1794 }
1795
1796 #[test]
1797 fn relative_path_matches_exact() {
1798 assert!(relative_path_matches_pattern(
1799 Path::new("services/api"),
1800 "services/api"
1801 ));
1802 }
1803
1804 #[test]
1805 fn relative_path_no_match_different_segment() {
1806 assert!(!relative_path_matches_pattern(
1807 Path::new("services/web"),
1808 "services/api"
1809 ));
1810 }
1811
1812 #[test]
1815 fn excluded_by_component_name() {
1816 let excludes = vec!["node_modules".to_string()];
1817 assert!(is_excluded_workspace_path(
1818 Path::new("node_modules"),
1819 &excludes
1820 ));
1821 }
1822
1823 #[test]
1824 fn excluded_by_nested_component() {
1825 let excludes = vec![".git".to_string()];
1826 assert!(is_excluded_workspace_path(
1827 Path::new("project/.git"),
1828 &excludes
1829 ));
1830 }
1831
1832 #[test]
1833 fn not_excluded_when_no_match() {
1834 let excludes = vec!["node_modules".to_string()];
1835 assert!(!is_excluded_workspace_path(
1836 Path::new("packages/auth"),
1837 &excludes
1838 ));
1839 }
1840
1841 #[test]
1844 fn resolve_workspace_root_goes_up_two_levels() {
1845 let path = Path::new("/project/.bv/workspace.yaml");
1846 assert_eq!(resolve_workspace_root(path), PathBuf::from("/project"));
1847 }
1848
1849 #[test]
1852 fn parse_issues_from_text_valid() {
1853 let text = "{\"id\":\"A\",\"title\":\"A\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n{\"id\":\"B\",\"title\":\"B\",\"status\":\"closed\",\"priority\":2,\"issue_type\":\"bug\"}\n";
1854 let issues = parse_issues_from_text(text).unwrap();
1855 assert_eq!(issues.len(), 2);
1856 assert_eq!(issues[0].id, "A");
1857 assert_eq!(issues[1].id, "B");
1858 }
1859
1860 #[test]
1861 fn parse_issues_from_text_skips_empty_lines() {
1862 let text = "\n{\"id\":\"A\",\"title\":\"A\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n\n";
1863 let issues = parse_issues_from_text(text).unwrap();
1864 assert_eq!(issues.len(), 1);
1865 }
1866
1867 #[test]
1868 fn parse_issues_from_text_strips_bom() {
1869 let text = "\u{feff}{\"id\":\"A\",\"title\":\"A\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n";
1870 let issues = parse_issues_from_text(text).unwrap();
1871 assert_eq!(issues.len(), 1);
1872 }
1873
1874 #[test]
1875 fn parse_issues_from_text_skips_malformed() {
1876 let text = "not json\n{\"id\":\"A\",\"title\":\"A\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n";
1877 let issues = parse_issues_from_text(text).unwrap();
1878 assert_eq!(issues.len(), 1);
1879 }
1880
1881 #[test]
1884 fn load_issues_skips_empty_lines() {
1885 let dir = tempfile::tempdir().unwrap();
1886 let path = dir.path().join("issues.jsonl");
1887 std::fs::write(
1888 &path,
1889 "\n{\"id\":\"A\",\"title\":\"A\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n\n",
1890 )
1891 .unwrap();
1892 let issues = load_issues_from_file(&path).unwrap();
1893 assert_eq!(issues.len(), 1);
1894 }
1895
1896 #[test]
1897 fn load_issues_strips_bom_on_first_line() {
1898 let dir = tempfile::tempdir().unwrap();
1899 let path = dir.path().join("issues.jsonl");
1900 std::fs::write(
1901 &path,
1902 "\u{feff}{\"id\":\"A\",\"title\":\"A\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1903 )
1904 .unwrap();
1905 let issues = load_issues_from_file(&path).unwrap();
1906 assert_eq!(issues.len(), 1);
1907 }
1908
1909 #[test]
1910 fn load_issues_skips_malformed_json_lines() {
1911 let dir = tempfile::tempdir().unwrap();
1912 let path = dir.path().join("issues.jsonl");
1913 std::fs::write(
1914 &path,
1915 "not json\n{\"id\":\"A\",\"title\":\"A\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
1916 )
1917 .unwrap();
1918 let issues = load_issues_from_file(&path).unwrap();
1919 assert_eq!(issues.len(), 1);
1920 }
1921
1922 #[test]
1925 fn find_jsonl_skips_backup_files() {
1926 let dir = tempfile::tempdir().unwrap();
1927 let beads_dir = dir.path();
1928 std::fs::write(beads_dir.join("issues.backup.jsonl"), "{}\n").unwrap();
1929 std::fs::write(beads_dir.join("issues.orig.jsonl"), "{}\n").unwrap();
1930 std::fs::write(beads_dir.join("beads.left.jsonl"), "{}\n").unwrap();
1931 std::fs::write(beads_dir.join("deletions.jsonl"), "{}\n").unwrap();
1932 std::fs::write(beads_dir.join("valid.jsonl"), "{}\n").unwrap();
1933
1934 let path = find_jsonl_path(beads_dir).unwrap();
1935 assert!(path.ends_with("valid.jsonl"));
1936 }
1937
1938 #[test]
1939 fn find_jsonl_error_when_empty_dir() {
1940 let dir = tempfile::tempdir().unwrap();
1941 let result = find_jsonl_path(dir.path());
1942 assert!(result.is_err());
1943 }
1944
1945 #[test]
1948 fn load_sprints_from_nonexistent_returns_empty() {
1949 let path = Path::new("/nonexistent/sprints.jsonl");
1950 let sprints = load_sprints_from_file(path).unwrap();
1951 assert!(sprints.is_empty());
1952 }
1953
1954 #[test]
1955 fn load_sprints_skips_sprint_without_id() {
1956 let dir = tempfile::tempdir().unwrap();
1957 let path = dir.path().join("sprints.jsonl");
1958 std::fs::write(
1959 &path,
1960 "{\"id\":\"\",\"name\":\"Bad Sprint\",\"bead_ids\":[]}\n{\"id\":\"s1\",\"name\":\"Good\",\"bead_ids\":[]}\n",
1961 )
1962 .unwrap();
1963 let sprints = load_sprints_from_file(&path).unwrap();
1964 assert_eq!(sprints.len(), 1);
1965 assert_eq!(sprints[0].id, "s1");
1966 }
1967
1968 #[test]
1971 fn workspace_validate_rejects_empty_repos() {
1972 let config = WorkspaceConfig::default();
1973 assert!(config.validate().is_err());
1974 }
1975
1976 #[test]
1977 fn workspace_validate_rejects_all_disabled() {
1978 let config = WorkspaceConfig {
1979 repos: vec![WorkspaceRepoConfig {
1980 path: "api".to_string(),
1981 enabled: Some(false),
1982 ..Default::default()
1983 }],
1984 ..Default::default()
1985 };
1986 assert!(config.validate().is_err());
1987 }
1988
1989 #[test]
1990 fn workspace_validate_rejects_empty_path() {
1991 let config = WorkspaceConfig {
1992 repos: vec![WorkspaceRepoConfig {
1993 path: " ".to_string(),
1994 ..Default::default()
1995 }],
1996 ..Default::default()
1997 };
1998 assert!(config.validate().is_err());
1999 }
2000
2001 #[test]
2004 fn namespace_issues_qualifies_comment_issue_ids() {
2005 let mut issues = vec![Issue {
2006 id: "A".to_string(),
2007 title: "T".to_string(),
2008 status: "open".to_string(),
2009 issue_type: "task".to_string(),
2010 comments: vec![crate::model::Comment {
2011 id: 1,
2012 issue_id: "A".to_string(),
2013 author: "dev".to_string(),
2014 text: "hello".to_string(),
2015 ..Default::default()
2016 }],
2017 ..Default::default()
2018 }];
2019 namespace_workspace_issues(&mut issues, "api-", "api", &[]);
2020 assert_eq!(issues[0].comments[0].issue_id, "api-A");
2021 }
2022
2023 #[test]
2024 fn namespace_issues_sets_source_repo() {
2025 let mut issues = vec![Issue {
2026 id: "A".to_string(),
2027 title: "T".to_string(),
2028 status: "open".to_string(),
2029 issue_type: "task".to_string(),
2030 ..Default::default()
2031 }];
2032 namespace_workspace_issues(&mut issues, "api-", "my-api", &[]);
2033 assert_eq!(issues[0].source_repo, "my-api");
2034 }
2035
2036 #[test]
2037 fn load_workspace_config_rejects_duplicate_prefixes() {
2038 let dir = tempfile::tempdir().expect("tempdir");
2039 let root = dir.path();
2040 let workspace_dir = root.join(".bv");
2041 std::fs::create_dir_all(&workspace_dir).expect("create .bv");
2042 let config_path = workspace_dir.join("workspace.yaml");
2043 std::fs::write(
2044 &config_path,
2045 "repos:\n - path: services/api\n prefix: app-\n - path: services/web\n prefix: app-\n",
2046 )
2047 .expect("write config");
2048
2049 let error = load_workspace_config(&config_path).expect_err("duplicate prefixes rejected");
2050 assert!(error.to_string().contains("duplicate prefix"));
2051 }
2052
2053 #[test]
2054 fn load_workspace_config_rejects_duplicate_repo_path_aliases() {
2055 let dir = tempfile::tempdir().expect("tempdir");
2056 let root = dir.path();
2057 std::fs::create_dir_all(root.join("services/api/.beads")).expect("create repo");
2058
2059 let workspace_dir = root.join(".bv");
2060 std::fs::create_dir_all(&workspace_dir).expect("create .bv");
2061 let config_path = workspace_dir.join("workspace.yaml");
2062 std::fs::write(
2063 &config_path,
2064 concat!(
2065 "repos:\n",
2066 " - path: services/api\n",
2067 " prefix: api-\n",
2068 " - path: services/./api\n",
2069 " prefix: backend-\n",
2070 ),
2071 )
2072 .expect("write config");
2073
2074 let error =
2075 load_workspace_config(&config_path).expect_err("duplicate repo aliases rejected");
2076 assert!(error.to_string().contains("duplicates repository path"));
2077 }
2078
2079 #[test]
2080 fn load_workspace_config_rejects_duplicate_missing_repo_aliases() {
2081 let dir = tempfile::tempdir().expect("tempdir");
2082 let root = dir.path();
2083
2084 let workspace_dir = root.join(".bv");
2085 std::fs::create_dir_all(&workspace_dir).expect("create .bv");
2086 let config_path = workspace_dir.join("workspace.yaml");
2087 std::fs::write(
2088 &config_path,
2089 concat!(
2090 "repos:\n",
2091 " - path: services/api\n",
2092 " prefix: api-\n",
2093 " - path: services/./api\n",
2094 " prefix: backend-\n",
2095 ),
2096 )
2097 .expect("write config");
2098
2099 let error = load_workspace_config(&config_path)
2100 .expect_err("duplicate missing repo aliases rejected");
2101 assert!(error.to_string().contains("duplicates repository path"));
2102 }
2103
2104 #[test]
2105 fn load_workspace_config_discovery_ignores_disabled_explicit_repo_aliases() {
2106 let dir = tempfile::tempdir().expect("tempdir");
2107 let root = dir.path();
2108 std::fs::create_dir_all(root.join(".bv")).expect("create .bv");
2109 std::fs::create_dir_all(root.join("services/api/.beads")).expect("create api .beads");
2110 std::fs::write(
2111 root.join(".bv/workspace.yaml"),
2112 concat!(
2113 "discovery:\n",
2114 " enabled: true\n",
2115 "repos:\n",
2116 " - name: disabled-api\n",
2117 " path: services/./api\n",
2118 " prefix: disabled-\n",
2119 " enabled: false\n",
2120 ),
2121 )
2122 .expect("write workspace config");
2123 std::fs::write(
2124 root.join("services/api/.beads/issues.jsonl"),
2125 "{\"id\":\"AUTH-1\",\"title\":\"API Auth\",\"status\":\"open\",\"priority\":1,\"issue_type\":\"task\"}\n",
2126 )
2127 .expect("write api issues");
2128
2129 let config =
2130 load_workspace_config(&root.join(".bv/workspace.yaml")).expect("load workspace config");
2131
2132 assert_eq!(
2133 config.repos.len(),
2134 2,
2135 "disabled explicit alias should not block discovery"
2136 );
2137 assert!(
2138 config
2139 .repos
2140 .iter()
2141 .any(|repo| repo.is_enabled() && repo.path == "services/api"),
2142 "discovery should still include the real repo path"
2143 );
2144 }
2145
2146 #[test]
2147 fn deduplicate_issues_keeps_last_occurrence() {
2148 let issues = vec![
2149 Issue {
2150 id: "A".into(),
2151 title: "first".into(),
2152 status: "open".into(),
2153 issue_type: "task".into(),
2154 ..Issue::default()
2155 },
2156 Issue {
2157 id: "B".into(),
2158 title: "B".into(),
2159 status: "open".into(),
2160 issue_type: "task".into(),
2161 ..Issue::default()
2162 },
2163 Issue {
2164 id: "A".into(),
2165 title: "updated".into(),
2166 status: "closed".into(),
2167 issue_type: "task".into(),
2168 ..Issue::default()
2169 },
2170 ];
2171 let deduped = super::deduplicate_issues(issues);
2172 assert_eq!(deduped.len(), 2);
2173 let a = deduped.iter().find(|i| i.id == "A").unwrap();
2174 assert_eq!(a.title, "updated");
2175 assert_eq!(a.status, "closed");
2176 }
2177
2178 #[test]
2179 fn deduplicate_issues_no_duplicates_is_noop() {
2180 let issues = vec![
2181 Issue {
2182 id: "A".into(),
2183 title: "A".into(),
2184 status: "open".into(),
2185 issue_type: "task".into(),
2186 ..Issue::default()
2187 },
2188 Issue {
2189 id: "B".into(),
2190 title: "B".into(),
2191 status: "open".into(),
2192 issue_type: "task".into(),
2193 ..Issue::default()
2194 },
2195 ];
2196 let deduped = super::deduplicate_issues(issues);
2197 assert_eq!(deduped.len(), 2);
2198 }
2199
2200 #[test]
2201 fn deduplicate_issues_empty_input() {
2202 let deduped = super::deduplicate_issues(vec![]);
2203 assert!(deduped.is_empty());
2204 }
2205
2206 #[test]
2207 fn deduplicate_issues_preserves_order() {
2208 let issues = vec![
2209 Issue {
2210 id: "C".into(),
2211 title: "C".into(),
2212 status: "open".into(),
2213 issue_type: "task".into(),
2214 ..Issue::default()
2215 },
2216 Issue {
2217 id: "A".into(),
2218 title: "A".into(),
2219 status: "open".into(),
2220 issue_type: "task".into(),
2221 ..Issue::default()
2222 },
2223 Issue {
2224 id: "B".into(),
2225 title: "B".into(),
2226 status: "open".into(),
2227 issue_type: "task".into(),
2228 ..Issue::default()
2229 },
2230 ];
2231 let deduped = super::deduplicate_issues(issues);
2232 let ids: Vec<&str> = deduped.iter().map(|i| i.id.as_str()).collect();
2233 assert_eq!(ids, vec!["C", "A", "B"]);
2234 }
2235}