Skip to main content

bvr/
loader.rs

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(&current_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
857/// Deduplicate issues by ID, keeping the last occurrence (JSONL files
858/// append updates, so later lines take precedence). Warns on duplicates.
859fn 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
937/// Parse issues from JSONL text (e.g., from `git show` output).
938pub 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    // --- qualify_id tests ---
1650
1651    #[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    // --- has_known_prefix tests ---
1667
1668    #[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    // --- WorkspaceRepoConfig methods ---
1688
1689    #[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    // --- normalize_path_for_display ---
1766
1767    #[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    // --- relative_path_matches_pattern ---
1774
1775    #[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    // --- is_excluded_workspace_path ---
1813
1814    #[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    // --- resolve_workspace_root ---
1842
1843    #[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    // --- parse_issues_from_text ---
1850
1851    #[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    // --- load_issues_from_file edge cases ---
1882
1883    #[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    // --- find_jsonl_path edge cases ---
1923
1924    #[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    // --- load_sprints edge cases ---
1946
1947    #[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    // --- WorkspaceConfig::validate ---
1969
1970    #[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    // --- namespace_workspace_issues ---
2002
2003    #[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}