use std::fs;
use std::path::{Path, PathBuf};
use chrono::{SecondsFormat, Utc};
use serde::Serialize;
use crate::config_loader::load_project_configuration;
use crate::error::KanbusError;
use crate::file_io::{
find_project_local_directory, get_configuration_path, resolve_labeled_projects,
};
use crate::migration::load_beads_issues;
use crate::models::{IssueData, ProjectConfiguration};
#[derive(Debug, Clone, Serialize)]
pub struct ConsoleSnapshot {
pub config: ProjectConfiguration,
pub issues: Vec<IssueData>,
pub updated_at: String,
}
#[derive(Debug, Clone)]
pub struct FileStore {
root: PathBuf,
}
impl FileStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn resolve_tenant_root(base: &Path, account: &str, project: &str) -> PathBuf {
base.join(account).join(project)
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn load_config(&self) -> Result<ProjectConfiguration, KanbusError> {
let configuration_path = get_configuration_path(self.root())?;
load_project_configuration(&configuration_path)
}
pub fn load_issues(
&self,
configuration: &ProjectConfiguration,
) -> Result<Vec<IssueData>, KanbusError> {
if !configuration.virtual_projects.is_empty() {
return self.load_issues_with_virtual_projects();
}
if configuration.beads_compatibility {
load_beads_issues(self.root())
} else {
let project_dir = self.root().join(&configuration.project_directory);
load_console_issues(&project_dir)
}
}
fn load_issues_with_virtual_projects(&self) -> Result<Vec<IssueData>, KanbusError> {
let labeled = resolve_labeled_projects(self.root())?;
let mut all_issues = Vec::new();
for project in &labeled {
let issues_dir = project.project_dir.join("issues");
if issues_dir.is_dir() {
let mut shared = load_issues_from_dir(&issues_dir)?;
for issue in &mut shared {
tag_custom(issue, "project_label", &project.label);
tag_custom(issue, "source", "shared");
}
all_issues.append(&mut shared);
if let Some(local_dir) = find_project_local_directory(&project.project_dir) {
let local_issues_dir = local_dir.join("issues");
if local_issues_dir.is_dir() {
let mut local = load_issues_from_dir(&local_issues_dir)?;
for issue in &mut local {
tag_custom(issue, "project_label", &project.label);
tag_custom(issue, "source", "local");
}
all_issues.append(&mut local);
}
}
} else if let Some(repo_root) = project.project_dir.parent() {
let beads_path = repo_root.join(".beads").join("issues.jsonl");
if beads_path.exists() {
let mut issues = load_beads_issues(repo_root)?;
for issue in &mut issues {
tag_custom(issue, "project_label", &project.label);
tag_custom(issue, "source", "shared");
}
all_issues.append(&mut issues);
}
}
}
Ok(all_issues)
}
pub fn build_snapshot(&self) -> Result<ConsoleSnapshot, KanbusError> {
let configuration = self.load_config()?;
let mut issues = self.load_issues(&configuration)?;
issues.sort_by(|left, right| left.identifier.cmp(&right.identifier));
let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
Ok(ConsoleSnapshot {
config: configuration,
issues,
updated_at,
})
}
pub fn build_snapshot_payload(&self) -> Result<String, KanbusError> {
let snapshot = self.build_snapshot()?;
serde_json::to_string(&snapshot).map_err(|error| KanbusError::Io(error.to_string()))
}
}
pub fn find_issue_matches<'a>(
issues: &'a [IssueData],
identifier: &str,
project_key: &str,
) -> Vec<&'a IssueData> {
let mut matches = Vec::new();
for issue in issues {
if issue.identifier == identifier {
matches.push(issue);
continue;
}
if short_id_matches(identifier, project_key, &issue.identifier) {
matches.push(issue);
}
}
matches
}
fn short_id_matches(candidate: &str, project_key: &str, full_id: &str) -> bool {
if !candidate.starts_with(project_key) {
return false;
}
let mut parts = candidate.splitn(2, '-');
let prefix_key = parts.next().unwrap_or("");
let prefix = parts.next().unwrap_or("");
if prefix_key != project_key {
return false;
}
if prefix.is_empty() || prefix.len() > 6 {
return false;
}
let mut full_parts = full_id.splitn(2, '-');
let full_key = full_parts.next().unwrap_or("");
let full_suffix = full_parts.next().unwrap_or("");
if full_key != project_key {
return false;
}
full_suffix.starts_with(prefix)
}
fn load_issues_from_dir(issues_dir: &Path) -> Result<Vec<IssueData>, KanbusError> {
let mut issues = Vec::new();
for entry in fs::read_dir(issues_dir).map_err(|error| KanbusError::Io(error.to_string()))? {
let entry = entry.map_err(|error| KanbusError::Io(error.to_string()))?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let bytes = fs::read(&path)
.map_err(|_error| KanbusError::IssueOperation("issue file is invalid".to_string()))?;
let issue: IssueData = serde_json::from_slice(&bytes)
.map_err(|_error| KanbusError::IssueOperation("issue file is invalid".to_string()))?;
issues.push(issue);
}
Ok(issues)
}
fn load_console_issues(project_dir: &Path) -> Result<Vec<IssueData>, KanbusError> {
let issues_dir = project_dir.join("issues");
if !issues_dir.exists() || !issues_dir.is_dir() {
return Err(KanbusError::IssueOperation(
"project/issues directory not found".to_string(),
));
}
let mut issues = load_issues_from_dir(&issues_dir)?;
for issue in &mut issues {
tag_custom(issue, "source", "shared");
}
if let Some(local_dir) = find_project_local_directory(project_dir) {
let local_issues_dir = local_dir.join("issues");
if local_issues_dir.is_dir() {
let mut local_issues = load_issues_from_dir(&local_issues_dir)?;
for issue in &mut local_issues {
tag_custom(issue, "source", "local");
}
issues.extend(local_issues);
}
}
Ok(issues)
}
fn tag_custom(issue: &mut IssueData, key: &str, value: &str) {
issue.custom.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}