#![allow(unused_assignments)]
use std::collections::BTreeMap;
use HashMap;
pub mod conflict;
pub mod consensus;
impl<R: LocalReader> LocalIssueSource<R> {
fn new(local_path: LocalPath, reader: R) -> Self {
Self { local_path, reader }
}
pub fn child(&self, child_index: IssueIndex) -> Self {
Self {
local_path: LocalPath::new(child_index),
reader: self.reader,
}
}
pub fn index(&self) -> &IssueIndex {
&self.local_path.index
}
}
impl LocalIssueSource<FsReader> {
pub async fn build(local_path: LocalPath) -> Result<Self, LocalError> {
if std::process::Command::new("fd").arg("--version").output().is_err() {
return Err(LocalError::new_missing_executable("fd", "local filesystem operations"));
}
if let Some(conflict_file) = conflict::check_for_existing_conflict(local_path.index).await? {
eprintln!("Unresolved merge conflict in: {}", conflict_file.display());
eprintln!("Opening for resolution...");
let modified = v_utils::io::file_open::open(&conflict_file).await?;
if !modified {
return Err(ConflictBlockedError::new(conflict_file).into());
}
if let Some(conflict_file) = conflict::check_for_existing_conflict(local_path.index).await? {
return Err(ConflictBlockedError::new(conflict_file).into());
}
}
Ok(Self::new(local_path, FsReader))
}
pub async fn build_from_path(path: &Path) -> Result<Self, LocalError> {
let index = Local::extract_index_from_path(path).map_err(|e| LocalError::new_path_extraction(e.to_string()))?;
Self::build(LocalPath::new(index)).await
}
}
impl LocalIssueSource<GitReader> {
pub fn build(local_path: LocalPath) -> Result<Self, LocalError> {
if std::process::Command::new("git").arg("--version").output().is_err() {
return Err(LocalError::new_missing_executable("git", "consensus state operations"));
}
Ok(Self::new(local_path, GitReader))
}
}
impl Local {
pub const MAIN_ISSUE_FILENAME: &'static str = "__main__";
pub fn virtual_edit_path(issue: &crate::Issue) -> PathBuf {
let base: PathBuf = if crate::mocks::MockIssuesDir::get().is_some() || std::env::var("__IS_INTEGRATION_TEST").is_ok() {
Self::issues_dir().parent().unwrap().parent().unwrap().parent().unwrap().to_path_buf()
} else {
PathBuf::from("/tmp").join(env!("CARGO_PKG_NAME"))
};
let index_path = issue.full_index().to_string();
let vpath = base.join(format!("{index_path}.md"));
if let Some(parent) = vpath.parent() {
std::fs::create_dir_all(parent).unwrap();
}
vpath
}
pub fn issues_dir() -> PathBuf {
if let Some(override_dir) = crate::mocks::MockIssuesDir::get() {
return override_dir;
}
v_utils::xdg_data_dir!("issues")
}
pub fn project_dir(repo_info: RepoInfo) -> PathBuf {
Self::issues_dir().join(repo_info.owner()).join(repo_info.repo())
}
pub fn sanitize_title(title: &str) -> String {
title
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else if c == ' ' {
'_'
} else {
'\0'
}
})
.filter(|&c| c != '\0')
.collect::<String>()
.trim_matches('_')
.to_string()
}
fn __issue_base_name(issue_number: Option<u64>, sanitized: &str) -> String {
match issue_number {
Some(num) if sanitized.is_empty() => format!("{num}"),
Some(num) => format!("{num}_-_{sanitized}"),
None if sanitized.is_empty() => "untitled".to_string(),
None => sanitized.to_string(),
}
}
fn format_issue_filename(issue_number: Option<u64>, title: &str, closed: bool) -> String {
let sanitized = Self::sanitize_title(title);
let base = format!("{}.md", Self::__issue_base_name(issue_number, &sanitized));
if closed { format!("{base}.bak") } else { base }
}
pub fn issue_dir_name(issue_number: Option<u64>, title: &str, closed: bool) -> String {
let sanitized = Self::sanitize_title(title);
let base = Self::__issue_base_name(issue_number, &sanitized);
if closed { format!("{base}.bak") } else { base }
}
pub fn main_file_path(issue_dir: &Path, closed: bool) -> PathBuf {
let filename = if closed {
format!("{}.md.bak", Self::MAIN_ISSUE_FILENAME)
} else {
format!("{}.md", Self::MAIN_ISSUE_FILENAME)
};
issue_dir.join(filename)
}
pub fn main_file_in_dir(issue_dir: &Path) -> Option<PathBuf> {
let main_file = Self::main_file_path(issue_dir, false);
if main_file.exists() {
return Some(main_file);
}
let main_bak = Self::main_file_path(issue_dir, true);
if main_bak.exists() {
return Some(main_bak);
}
None
}
pub fn find_by_number(repo_info: RepoInfo, number: u64, _key: FsReader) -> Option<PathBuf> {
let project_dir = Self::project_dir(repo_info);
if !project_dir.exists() {
return None;
}
let pattern = format!("^{number}_-_");
let output = std::process::Command::new("fd")
.args(["--regex", &pattern])
.current_dir(&project_dir)
.output()
.expect("fd is not installed");
assert!(output.status.success(), "fd failed: {}", String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8(output.stdout).expect("fd output is not utf-8");
let entries: Vec<&str> = stdout.lines().filter(|l: &&str| !l.is_empty()).collect();
match entries.len() {
0 => None,
1 => {
let entry_path = project_dir.join(entries[0]);
if entry_path.is_dir() { Self::main_file_in_dir(&entry_path) } else { Some(entry_path) }
}
_ => panic!("ambiguous: multiple entries match issue #{number} in {}: {entries:?}", project_dir.display()),
}
}
pub fn parse_issue_selector_from_name(name: &str) -> Option<IssueSelector> {
let base = name.strip_suffix(".md.bak").or_else(|| name.strip_suffix(".md")).unwrap_or(name);
if let Some(sep_pos) = base.find("_-_") {
if let Ok(num) = base[..sep_pos].parse::<u64>() {
return Some(IssueSelector::GitId(num));
}
} else if let Ok(num) = base.parse::<u64>() {
return Some(IssueSelector::GitId(num));
}
IssueSelector::try_title(base)
}
pub fn extract_index_from_path(path: &Path) -> Result<IssueIndex> {
let issues_base = Self::issues_dir();
let rel_path = path.strip_prefix(&issues_base).map_err(|_| eyre!("Issue file is not in issues directory: {path:?}"))?;
let components: Vec<_> = rel_path.components().collect();
if components.len() < 3 {
bail!("Path too short to extract issue: {path:?}");
}
let owner = components[0].as_os_str().to_str().ok_or_else(|| eyre!("Could not extract owner from path: {path:?}"))?;
let repo = components[1].as_os_str().to_str().ok_or_else(|| eyre!("Could not extract repo from path: {path:?}"))?;
let filename = components
.last()
.expect("components verified to have at least 3 elements")
.as_os_str()
.to_str()
.ok_or_else(|| eyre!("Could not convert filename to str: {path:?}"))?;
let is_dir_format = filename.starts_with(Self::MAIN_ISSUE_FILENAME);
let mut selectors = Vec::new();
let dir_end = if is_dir_format {
components.len() - 2 } else {
components.len() - 1 };
for component in &components[2..dir_end] {
let name = component.as_os_str().to_str().ok_or_else(|| eyre!("Invalid path component: {component:?}"))?;
if name.ends_with(".md") || name.ends_with(".md.bak") {
continue;
}
if let Some(selector) = Self::parse_issue_selector_from_name(name) {
selectors.push(selector);
}
}
if is_dir_format {
let dir_name = components[components.len() - 2].as_os_str().to_str().ok_or_else(|| eyre!("Invalid directory component"))?;
if let Some(selector) = Self::parse_issue_selector_from_name(dir_name) {
selectors.push(selector);
}
} else {
if let Some(selector) = Self::parse_issue_selector_from_name(filename) {
selectors.push(selector);
}
}
Ok(IssueIndex::with_index(RepoInfo::new(owner, repo), selectors))
}
pub(crate) fn fzf_select(items: &[String], query: &str) -> Result<String> {
use std::{
io::Write,
process::{Command, Stdio},
};
let mut cmd = Command::new("fzf");
cmd.arg("--query")
.arg(query)
.arg("--select-1")
.arg("--preview")
.arg("cat {}")
.arg("--preview-window")
.arg("right:50%:wrap")
.current_dir(Self::issues_dir())
.stdin(Stdio::piped())
.stdout(Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
for item in items {
writeln!(stdin, "{item}")?;
}
}
let output = child.wait_with_output()?;
if output.status.success() {
let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !selected.is_empty() {
return Ok(selected);
}
}
bail!("No item selected")
}
pub fn fzf_issue(initial_query: &str, exact: ExactMatchLevel) -> Result<PathBuf> {
use std::{
io::Write,
process::{Command, Stdio},
};
fn collect_issue_files(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_issue_files(&path, files)?;
} else if path.is_file()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
&& (name.ends_with(".md") || name.ends_with(".md.bak"))
{
files.push(path);
}
}
Ok(())
}
let issues_base = Self::issues_dir();
if !issues_base.exists() {
bail!("No issue files found. Use a Github URL to fetch an issue first.");
}
let mut files = Vec::new();
collect_issue_files(&issues_base, &mut files)?;
if files.is_empty() {
bail!("No issue files found. Use a Github URL to fetch an issue first.");
}
if !matches!(exact, ExactMatchLevel::Fuzzy) {
files.sort_by(|a, b| {
let a_time = std::fs::metadata(a).and_then(|m| m.modified()).ok();
let b_time = std::fs::metadata(b).and_then(|m| m.modified()).ok();
b_time.cmp(&a_time)
});
}
let file_list: Vec<String> = files
.iter()
.filter_map(|p| p.strip_prefix(&issues_base).ok().map(|rel| rel.to_string_lossy().to_string()))
.collect();
let (filtered_list, fzf_query): (Vec<&String>, String) = match exact {
ExactMatchLevel::Fuzzy | ExactMatchLevel::ExactTerms => (file_list.iter().collect(), initial_query.to_string()),
ExactMatchLevel::RegexSubstring =>
if initial_query.is_empty() {
(file_list.iter().collect(), String::new())
} else {
let re = Regex::new(initial_query).map_err(|e| eyre!("Invalid regex pattern: {e}"))?;
let filtered: Vec<&String> = file_list.iter().filter(|f| re.is_match(f)).collect();
(filtered, String::new())
},
ExactMatchLevel::RegexLine =>
if initial_query.is_empty() {
(file_list.iter().collect(), String::new())
} else {
let pattern = {
let has_start = initial_query.starts_with('^');
let has_end = initial_query.ends_with('$');
match (has_start, has_end) {
(true, true) => initial_query.to_string(),
(true, false) => format!("{initial_query}$"),
(false, true) => format!("^{initial_query}"),
(false, false) => format!("^{initial_query}$"),
}
};
let re = Regex::new(&pattern).map_err(|e| eyre!("Invalid regex pattern: {e}"))?;
let filtered: Vec<&String> = file_list.iter().filter(|f| re.is_match(f)).collect();
(filtered, String::new())
},
};
let owned: Vec<String> = filtered_list.iter().map(|s| s.to_string()).collect();
if matches!(exact, ExactMatchLevel::ExactTerms) {
let mut cmd = Command::new("fzf");
cmd.arg("--query")
.arg(&fzf_query)
.arg("--exact")
.arg("--select-1")
.arg("--preview")
.arg("cat {}")
.arg("--preview-window")
.arg("right:50%:wrap")
.current_dir(&issues_base)
.stdin(Stdio::piped())
.stdout(Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(stdin) = child.stdin.as_mut() {
for file in &owned {
writeln!(stdin, "{file}")?;
}
}
let output = child.wait_with_output()?;
if output.status.success() {
let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !selected.is_empty() {
return Ok(issues_base.join(selected));
}
}
bail!("No issue selected")
} else {
let selected = Self::fzf_select(&owned, &fzf_query)?;
Ok(issues_base.join(selected))
}
}
pub fn is_virtual_project(repo_info: RepoInfo) -> bool {
Self::load_project_meta(repo_info).virtual_project
}
pub fn ensure_virtual_project(repo_info: RepoInfo) -> Result<ProjectMeta> {
let meta_path = Self::project_meta_path(repo_info);
if meta_path.exists() {
let project_meta = Self::load_project_meta(repo_info);
if !project_meta.virtual_project {
bail!("Project {}/{} exists but is not a virtual project", repo_info.owner(), repo_info.repo());
}
Ok(project_meta)
} else {
let project_meta = ProjectMeta {
virtual_project: true,
next_virtual_issue_number: 1,
issues: BTreeMap::new(),
};
Self::save_project_meta(repo_info, &project_meta)?;
Ok(project_meta)
}
}
pub fn allocate_virtual_issue_number(repo_info: RepoInfo) -> Result<u64> {
let mut project_meta = Self::load_project_meta(repo_info);
if !project_meta.virtual_project {
bail!("Cannot allocate virtual issue number for non-virtual project {}/{}", repo_info.owner(), repo_info.repo());
}
if project_meta.next_virtual_issue_number == 0 {
project_meta.next_virtual_issue_number = 1;
}
let issue_number = project_meta.next_virtual_issue_number;
project_meta.next_virtual_issue_number += 1;
Self::save_project_meta(repo_info, &project_meta)?;
Ok(issue_number)
}
fn project_meta_path(repo_info: RepoInfo) -> PathBuf {
Self::project_dir(repo_info).join(".meta.json")
}
pub fn load_project_meta(repo_info: RepoInfo) -> ProjectMeta {
Self::load_project_meta_from_reader(repo_info, &FsReader)
}
fn save_project_meta(repo_info: RepoInfo, meta: &ProjectMeta) -> std::io::Result<()> {
let meta_path = Self::project_meta_path(repo_info);
if let Some(parent) = meta_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(meta).expect("ProjectMeta serialization is infallible");
let tmp_path = meta_path.with_extension("json.tmp");
std::fs::write(&tmp_path, &content)?;
std::fs::rename(&tmp_path, &meta_path)?;
Ok(())
}
#[instrument(skip(reader))]
pub(crate) fn load_project_meta_from_reader<R: LocalReader>(repo_info: RepoInfo, reader: &R) -> ProjectMeta {
let meta_path = Self::project_meta_path(repo_info);
match reader.read_content(&meta_path) {
Ok(c) if c.trim().is_empty() => ProjectMeta::default(),
Ok(c) => match serde_json::from_str(&c) {
Ok(meta) => meta,
Err(e) => panic!("corrupted project metadata at {}: {e}", meta_path.display()),
},
Err(e) if e.is_not_found() => ProjectMeta::default(),
Err(e) => panic!("failed to read project metadata at {}: {e}", meta_path.display()),
}
}
#[instrument(skip(meta), fields(issue_number))]
pub fn save_issue_meta(repo_info: RepoInfo, issue_number: u64, meta: &IssueMeta) -> std::io::Result<()> {
let mut project_meta = Self::load_project_meta(repo_info);
project_meta.issues.insert(issue_number, meta.clone());
Self::save_project_meta(repo_info, &project_meta)
}
#[instrument(skip(repo_info), fields(issue_number))]
fn remove_issue_meta(repo_info: RepoInfo, issue_number: u64) -> std::io::Result<()> {
let mut project_meta = Self::load_project_meta(repo_info);
if project_meta.issues.remove(&issue_number).is_some() {
Self::save_project_meta(repo_info, &project_meta)?;
}
Ok(())
}
pub fn read_hollow_from_project_meta(idx: IssueIndex) -> Result<crate::HollowIssue, LocalError> {
let repo_info = idx.repo_info();
let project_meta = Self::load_project_meta(repo_info);
let parent_is_git_id = idx.index().last().is_some_and(|s| matches!(s, IssueSelector::GitId(_)));
let remote = if let Some(IssueSelector::GitId(n)) = idx.index().last() {
let issue_meta = project_meta.issues.get(n);
let user = issue_meta.and_then(|m| m.user.clone());
let timestamps = issue_meta.map(|m| m.timestamps.clone()).unwrap_or_default();
let link = crate::IssueLink::parse(&format!("https://github.com/{}/{}/issues/{n}", repo_info.owner(), repo_info.repo())).expect("constructed link must be valid");
Some(Box::new(crate::LinkedIssueMeta::new(user, link, timestamps)))
} else {
None
};
let local_path = LocalPath::new(idx);
let issue_dir = match local_path.resolve_parent(FsReader) {
Ok(resolved) => resolved.search().ok().and_then(|r| r.issue_dir()),
Err(_) => None,
};
let mut children = HashMap::new();
if let Some(dir_path) = issue_dir {
let entries = FsReader.list_dir(&dir_path)?;
for name in entries {
if name.starts_with(Self::MAIN_ISSUE_FILENAME) {
continue;
}
let Some(child_selector) = Self::parse_issue_selector_from_name(&name) else {
continue;
};
if matches!(child_selector, IssueSelector::GitId(_)) && !parent_is_git_id {
return Err(LocalError::Other(eyre!("child issue {child_selector:?} has GitId but parent {:?} does not", idx.index().last())));
}
let child_idx = idx.child(child_selector);
let child_hollow = Self::read_hollow_from_project_meta(child_idx)?;
children.insert(child_selector, child_hollow);
}
}
Ok(crate::HollowIssue::new(remote, children))
}
}
mod reader;
pub use reader::{FsReader, GitReader, LocalReader, ReaderError, ReaderErrorKind};
mod local_path {
use std::{
backtrace::Backtrace,
collections::VecDeque,
path::{Path, PathBuf},
};
use miette::SourceSpan;
use tracing_error::SpanTrace;
use super::*;
#[derive(Debug, thiserror::Error)]
#[error("{rendered}\n\n{spantrace}")]
pub struct LocalPathError {
pub kind: LocalPathErrorKind,
rendered: String,
spantrace: SpanTrace,
backtrace: Backtrace,
}
impl LocalPathError {
fn from_diagnostic(kind: LocalPathErrorKind, diag: LocalPathDiagnostic) -> Self {
let rendered = format!("{:?}", miette::Report::new(diag));
Self {
kind,
rendered,
spantrace: SpanTrace::capture(),
backtrace: Backtrace::capture(),
}
}
pub fn missing_parent(selector: IssueSelector, searched_path: PathBuf) -> Self {
Self::from_diagnostic(LocalPathErrorKind::MissingParent, LocalPathDiagnostic::MissingParent { selector, searched_path })
}
pub fn not_found(selector: IssueSelector, searched_path: PathBuf) -> Self {
Self::from_diagnostic(LocalPathErrorKind::NotFound, LocalPathDiagnostic::NotFound { selector, searched_path })
}
pub fn not_unique(selector: IssueSelector, searched_path: PathBuf, matching_paths: Vec<PathBuf>) -> Self {
let paths_display = matching_paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("\n");
let first_path_len = matching_paths.first().map(|p| p.display().to_string().len()).unwrap_or(0);
Self::from_diagnostic(
LocalPathErrorKind::NotUnique,
LocalPathDiagnostic::NotUnique {
selector,
searched_path,
paths_source: miette::NamedSource::new("matching files", paths_display),
span: (0, first_path_len).into(),
matching_paths,
},
)
}
pub fn reader(selector: IssueSelector, source: ReaderError) -> Self {
Self::from_diagnostic(LocalPathErrorKind::Reader, LocalPathDiagnostic::Reader { selector, source })
}
pub fn parent_is_flat(selector: IssueSelector, parent_file: PathBuf, source: ReaderError) -> Self {
let path_str = parent_file.display().to_string();
let last_component = parent_file.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let span_start = path_str.len().saturating_sub(last_component.len());
let span_len = last_component.len();
Self::from_diagnostic(
LocalPathErrorKind::ParentIsFlat,
LocalPathDiagnostic::ParentIsFlat {
selector,
path_source: miette::NamedSource::new("parent path", path_str),
span: (span_start, span_len).into(),
source,
},
)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LocalPathErrorKind {
MissingParent,
NotFound,
NotUnique,
ParentIsFlat,
Reader,
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
enum LocalPathDiagnostic {
#[error("parent issue {selector:?} not found")]
#[diagnostic(
code(tedi::local::missing_parent),
help("Fetch the parent issue first.\n Searched in: {}", searched_path.display())
)]
MissingParent { selector: IssueSelector, searched_path: PathBuf },
#[error("issue file {selector:?} not found")]
#[diagnostic(
code(tedi::local::not_found),
help("Searched in: {}", searched_path.display())
)]
NotFound { selector: IssueSelector, searched_path: PathBuf },
#[error("multiple files match {selector:?}")]
#[diagnostic(code(tedi::local::not_unique), help("Specify a more precise selector to disambiguate"))]
NotUnique {
selector: IssueSelector,
searched_path: PathBuf,
#[source_code]
paths_source: miette::NamedSource<String>,
#[label("conflicts with other matches below")]
span: SourceSpan,
matching_paths: Vec<PathBuf>,
},
#[error("while resolving {selector:?}")]
#[diagnostic(code(tedi::local::reader))]
Reader {
selector: IssueSelector,
#[source]
source: ReaderError,
},
#[error("cannot search for {selector:?} - parent is a flat file, not a directory")]
#[diagnostic(
code(tedi::local::parent_is_flat),
help("The parent issue exists as a flat file. It will be converted to directory format when a sub-issue is added.")
)]
ParentIsFlat {
selector: IssueSelector,
#[source_code]
path_source: miette::NamedSource<String>,
#[label("this is a flat file, cannot contain children")]
span: SourceSpan,
#[source]
source: ReaderError,
},
}
#[derive(Clone, Debug)]
pub(crate) enum FoundEntry {
Dir(String),
File(String),
}
#[instrument(skip(reader), fields(parent = %parent.display()))]
pub(crate) fn find_all_entries_by_selector<R: LocalReader>(reader: &R, parent: &Path, selector: &IssueSelector) -> Result<Vec<FoundEntry>, ReaderError> {
let entries = reader.list_dir(parent)?;
let mut results = Vec::new();
for name in &entries {
fn entry_matches_selector<R: LocalReader>(reader: &R, parent: &Path, name: &str, selector: &IssueSelector) -> Result<Option<FoundEntry>, ReaderError> {
let entry_path = parent.join(name);
let is_dir = reader.is_dir(&entry_path)?;
let matches = match selector {
IssueSelector::GitId(issue_number) => {
let prefix = format!("{issue_number}_-_");
name.starts_with(&prefix)
}
IssueSelector::Title(title) => {
let sanitized = Local::sanitize_title(title.as_str());
name.contains(&sanitized)
}
IssueSelector::Regex(pattern) => {
let base = name.strip_suffix(".md.bak").or_else(|| name.strip_suffix(".md")).unwrap_or(name);
regex::Regex::new(pattern.as_str())
.map(|re| re.is_match(base))
.unwrap_or_else(|_| base.contains(pattern.as_str()))
}
};
if matches {
return Ok(Some(if is_dir { FoundEntry::Dir(name.to_string()) } else { FoundEntry::File(name.to_string()) }));
}
Ok(None)
}
if let Some(entry) = entry_matches_selector(reader, parent, name, selector)? {
results.push(entry);
}
}
Ok(results)
}
#[derive(Clone, Debug)]
pub struct LocalPath {
pub(crate) index: IssueIndex,
}
impl LocalPath {
pub fn new(index: IssueIndex) -> Self {
Self { index }
}
#[tracing::instrument(skip(reader))]
pub fn resolve_parent<R: LocalReader>(self, reader: R) -> Result<LocalPathResolved<R>, LocalPathError> {
let mut path = Local::project_dir(self.index.repo_info());
let selectors = self.index.index();
let (parent_selectors, remaining) = if selectors.is_empty() {
(&[][..], VecDeque::new())
} else {
(&selectors[..selectors.len() - 1], VecDeque::from(vec![selectors.last().cloned().unwrap()]))
};
for selector in parent_selectors {
let all_matches = find_all_entries_by_selector(&reader, &path, selector).map_err(|source| LocalPathError::reader(*selector, source))?;
path = match all_matches.len() {
0 => return Err(LocalPathError::missing_parent(*selector, path.clone())),
n => {
let matching_paths: Vec<PathBuf> = all_matches
.iter()
.map(|entry| {
path.join(match entry {
FoundEntry::Dir(name) => format!("{name}/"),
FoundEntry::File(name) => format!("{}/", name.strip_suffix(".md.bak").or_else(|| name.strip_suffix(".md")).unwrap()),
})
})
.collect();
match n {
1 => matching_paths.into_iter().take(1).collect(),
_ => return Err(LocalPathError::not_unique(*selector, path.clone(), matching_paths)),
}
}
};
}
Ok(LocalPathResolved {
resolved_path: path,
unresolved_selector_nodes: remaining,
reader,
})
}
}
impl From<IssueIndex> for LocalPath {
fn from(index: IssueIndex) -> Self {
Self::new(index)
}
}
impl From<&Issue> for LocalPath {
fn from(issue: &Issue) -> Self {
Self::new(IssueIndex::from(issue))
}
}
#[derive(Clone, Debug)]
pub struct LocalPathResolved<R: LocalReader> {
resolved_path: PathBuf,
unresolved_selector_nodes: VecDeque<IssueSelector>,
reader: R,
}
impl<R: LocalReader> LocalPathResolved<R> {
#[tracing::instrument(skip(self), fields(resolved_path = %self.resolved_path.display(), selector = ?self.unresolved_selector_nodes.front()))]
pub fn search(mut self) -> Result<Self, LocalPathError> {
let selector = self.unresolved_selector_nodes.pop_front().expect("Cannot search with empty selectors");
let all_matches = find_all_entries_by_selector(&self.reader, &self.resolved_path, &selector).map_err(|source| {
let path_str = self.resolved_path.to_string_lossy();
let potential_flat_file = if let Some(stripped) = path_str.strip_suffix('/') {
PathBuf::from(format!("{stripped}.md"))
} else {
PathBuf::from(format!("{path_str}.md"))
};
if source.kind == super::ReaderErrorKind::NotFound && self.reader.exists(&potential_flat_file).unwrap_or(false) {
LocalPathError::parent_is_flat(selector, potential_flat_file, source)
} else if source.kind == super::ReaderErrorKind::NotADirectory {
LocalPathError::parent_is_flat(selector, self.resolved_path.clone(), source)
} else {
LocalPathError::reader(selector, source)
}
})?;
match all_matches.len() {
0 => return Err(LocalPathError::not_found(selector, self.resolved_path.clone())),
1 => {}
_ => {
let matching_paths = all_matches
.iter()
.map(|e| {
self.resolved_path.join(match e {
FoundEntry::Dir(name) | FoundEntry::File(name) => name,
})
})
.collect();
return Err(LocalPathError::not_unique(selector, self.resolved_path.clone(), matching_paths));
}
}
let map_err = |source| LocalPathError::reader(selector, source);
match &all_matches[0] {
FoundEntry::Dir(name) => {
let dir_path = self.resolved_path.join(name);
let main_open = Local::main_file_path(&dir_path, false);
if self.reader.exists(&main_open).map_err(map_err)? {
self.resolved_path = main_open;
} else {
let main_closed = Local::main_file_path(&dir_path, true);
if self.reader.exists(&main_closed).map_err(map_err)? {
self.resolved_path = main_closed;
} else {
return Err(LocalPathError::not_found(selector, dir_path));
}
}
}
FoundEntry::File(name) => {
self.resolved_path = self.resolved_path.join(name);
}
};
Ok(self)
}
pub fn deterministic(mut self, title: &str, closed: bool, has_children: bool) -> Self {
let selector = self
.unresolved_selector_nodes
.pop_front()
.expect("implementation error, - this should only be called when we know it's not empty");
let git_id = match selector {
IssueSelector::GitId(n) => Some(n),
_ => None,
};
if has_children {
let dir_name = Local::issue_dir_name(git_id, title, closed);
let issue_dir = self.resolved_path.join(&dir_name);
self.resolved_path = Local::main_file_path(&issue_dir, closed);
} else {
let filename = Local::format_issue_filename(git_id, title, closed);
self.resolved_path = self.resolved_path.join(filename);
};
self
}
pub fn path(self) -> PathBuf {
assert!(
self.unresolved_selector_nodes.is_empty(),
"path() called with remaining selectors: {:?}",
self.unresolved_selector_nodes
);
self.resolved_path
}
#[tracing::instrument(skip(self), fields(resolved_path = %self.resolved_path.display()))]
pub fn issue_dir(self) -> Option<PathBuf> {
assert!(
self.unresolved_selector_nodes.is_empty(),
"issue_dir() called with remaining selectors: {:?}",
self.unresolved_selector_nodes
);
if self.reader.is_dir(&self.resolved_path).unwrap() {
Some(self.resolved_path)
} else {
let is_main_file = self
.resolved_path
.file_name()
.map(|n| n.to_string_lossy().starts_with(Local::MAIN_ISSUE_FILENAME))
.unwrap_or(false);
if is_main_file { self.resolved_path.parent().map(|p| p.to_path_buf()) } else { None }
}
}
#[tracing::instrument(skip(self), fields(resolved_path = %self.resolved_path.display(), selector = ?self.unresolved_selector_nodes.front()))]
pub fn matching_subpaths(&self) -> Result<Vec<PathBuf>, LocalPathError> {
let Some(selector) = self.unresolved_selector_nodes.front() else {
tracing::trace!("no selector in queue, returning empty");
return Ok(Vec::new());
};
let map_err = |source| LocalPathError::reader(*selector, source);
let all_matches = find_all_entries_by_selector(&self.reader, &self.resolved_path, selector).map_err(map_err)?;
tracing::debug!(?all_matches, "find_all_entries_by_selector returned");
let mut results = Vec::new();
for entry in all_matches {
match entry {
FoundEntry::Dir(name) => {
let dir_path = self.resolved_path.join(&name);
let main_open = Local::main_file_path(&dir_path, false);
if self.reader.exists(&main_open).map_err(map_err)? {
tracing::trace!(path = %main_open.display(), "found open __main__.md in dir");
results.push(main_open);
} else {
let main_closed = Local::main_file_path(&dir_path, true);
if self.reader.exists(&main_closed).map_err(map_err)? {
tracing::trace!(path = %main_closed.display(), "found closed __main__.md.bak in dir");
results.push(main_closed);
} else {
tracing::warn!(dir = %dir_path.display(), "directory exists but has no __main__.md - skipping");
}
}
}
FoundEntry::File(name) => {
let file_path = self.resolved_path.join(&name);
tracing::trace!(path = %file_path.display(), "found flat file");
results.push(file_path);
}
}
}
tracing::debug!(?results, "matching_subpaths returning");
Ok(results)
}
}
}
pub use local_path::{LocalPath, LocalPathError, LocalPathErrorKind, LocalPathResolved};
mod fs_sink;
use std::path::{Path, PathBuf};
pub use consensus::Consensus;
pub use fs_sink::{LocalFs, LocalFsSinkError};
use regex::Regex;
use serde::{Deserialize, Serialize};
use v_utils::{macros::wrap_err, prelude::*};
use crate::{Issue, IssueIndex, IssueLink, IssueSelector, LinkedIssueMeta, RepoInfo, local::conflict::ConflictBlockedError};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct IssueMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(default)]
pub timestamps: crate::IssueTimestamps,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ProjectMeta {
#[serde(default)]
pub virtual_project: bool,
#[serde(default)]
pub next_virtual_issue_number: u64,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub issues: BTreeMap<u64, IssueMeta>,
}
#[derive(Clone, Copy, Debug, Default)]
pub enum ExactMatchLevel {
#[default]
Fuzzy,
ExactTerms,
RegexSubstring,
RegexLine,
}
pub enum Local {}
#[derive(Clone, Debug)]
pub struct LocalIssueSource<R: LocalReader> {
pub local_path: LocalPath,
pub reader: R,
}
#[wrap_err]
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
pub enum ConsensusSinkError {
#[error("failed to write issue files: {_0}")]
#[diagnostic(code(tedi::consensus::write))]
Write(
#[from]
#[backtrace]
fs_sink::LocalFsSinkError,
),
#[leaf]
#[error("git add failed: {msg}")]
#[diagnostic(code(tedi::consensus::git_add))]
GitAdd { msg: String },
#[leaf]
#[error("git status failed: {msg}")]
#[diagnostic(code(tedi::consensus::git_status))]
GitStatus { msg: String },
#[leaf]
#[error("git commit failed: {msg}")]
#[diagnostic(code(tedi::consensus::git_commit))]
GitCommit { msg: String },
#[leaf]
#[error("files rejected by .gitignore:\n{msg}")]
#[diagnostic(code(tedi::consensus::gitignore), help("Check your .gitignore rules or remove the conflicting patterns"))]
GitIgnoreRejection { msg: String },
#[leaf]
#[error("invalid data directory path (not valid UTF-8)")]
#[diagnostic(code(tedi::consensus::invalid_path))]
InvalidDataDir,
#[foreign]
Io(std::io::Error),
}
#[wrap_err]
#[derive(Debug, thiserror::Error)]
pub enum LocalError {
#[own]
Io(LocalPathError),
#[own]
Parse(crate::ParseError),
#[own]
Issue(crate::IssueError),
#[own]
Reader(ReaderError),
#[leaf]
#[error("git operation failed: {message}")]
GitError { message: String },
#[own]
ConflictBlocked(conflict::ConflictBlockedError),
#[leaf]
#[error("`{executable}` not found in PATH (required for {operation})")]
MissingExecutable { executable: &'static str, operation: &'static str },
#[leaf]
#[error("failed to extract issue index from path: {msg}")]
PathExtraction { msg: String },
#[error(transparent)]
Other(#[from] Report),
}
impl TryFrom<u8> for ExactMatchLevel {
type Error = &'static str;
fn try_from(count: u8) -> Result<Self, Self::Error> {
match count {
0 => Ok(Self::Fuzzy),
1 => Ok(Self::ExactTerms),
2 => Ok(Self::RegexSubstring),
3 => Ok(Self::RegexLine),
_ => Err("--exact / -e can be specified at most 3 times"),
}
}
}
impl<R: LocalReader> crate::LazyIssue<LocalIssueSource<R>> for Issue {
type Error = LocalError;
#[tracing::instrument(skip_all)]
async fn parent_index(source: &LocalIssueSource<R>) -> Result<Option<crate::IssueIndex>, Self::Error> {
let index = source.index();
Ok(index.parent())
}
#[tracing::instrument(skip_all)]
async fn identity(&mut self, source: LocalIssueSource<R>) -> Result<crate::IssueIdentity, Self::Error> {
if self.identity.is_linked() {
return Ok(self.identity.clone());
}
let index = *source.index();
if self.contents.title.is_empty() {
let file_path = source.local_path.clone().resolve_parent(source.reader)?.search()?.path();
let content = source.reader.read_content(&file_path)?;
let parsed = crate::VirtualIssue::parse(&content, file_path)?;
self.contents = parsed.contents;
let remote = match parsed.selector {
IssueSelector::GitId(n) => {
let repo_info = index.repo_info();
let meta = Local::load_project_meta(repo_info).issues.remove(&n);
let link = IssueLink::parse(&format!("https://github.com/{}/{}/issues/{n}", repo_info.owner(), repo_info.repo())).expect("constructed URL must be valid");
Some(Box::new(LinkedIssueMeta::new(
meta.as_ref().and_then(|m| m.user.clone()),
link,
meta.map(|m| m.timestamps).unwrap_or_default(),
)))
}
_ => None,
};
self.identity.parent_index = index.parent().unwrap_or_else(|| IssueIndex::repo_only(index.repo_info()));
self.identity.is_virtual = Local::is_virtual_project(index.repo_info());
self.identity.remote = remote;
}
Ok(self.identity.clone())
}
#[tracing::instrument(skip(self, source))]
async fn contents(&mut self, source: LocalIssueSource<R>) -> Result<crate::IssueContents, Self::Error> {
if !self.contents.title.is_empty() {
return Ok(self.contents.clone());
}
let index = *source.index();
let file_path = source.local_path.clone().resolve_parent(source.reader)?.search()?.path();
let content = source.reader.read_content(&file_path)?;
let parsed = crate::VirtualIssue::parse(&content, file_path)?;
self.contents = parsed.contents;
self.identity.parent_index = index.parent().unwrap_or_else(|| IssueIndex::repo_only(index.repo_info()));
self.identity.is_virtual = Local::is_virtual_project(index.repo_info());
Ok(self.contents.clone())
}
#[tracing::instrument(skip_all)]
async fn children(&mut self, source: LocalIssueSource<R>) -> Result<HashMap<IssueSelector, Issue>, Self::Error> {
if !self.children.is_empty() {
return Ok(self.children.clone());
}
let dir_path = source.local_path.clone().resolve_parent(source.reader)?.search()?.issue_dir();
let Some(dir_path) = dir_path else {
return Ok(HashMap::new()); };
let entries = source.reader.list_dir(&dir_path)?;
let mut children = HashMap::new();
let this_index = *source.index();
for name in entries {
if name.starts_with(Local::MAIN_ISSUE_FILENAME) {
continue;
}
let child_selector = match Local::parse_issue_selector_from_name(&name) {
Some(sel) => sel,
None => continue,
};
let child_index = this_index.child(child_selector);
let child_source = source.child(child_index);
let child = Issue::load(child_source).await?;
children.insert(child.selector(), child);
}
self.children = children.clone();
Ok(children)
}
}