use std::{
collections::HashMap,
path::{Path, PathBuf},
};
use tracing::{debug, info, instrument, trace, warn};
use v_utils::{macros::wrap_err, prelude::*};
use super::{FsReader, IssueMeta, Local, LocalPath, LocalReader, local_path::LocalPathError};
use crate::{Issue, RepoInfo, local::LocalPathErrorKind, sink::Sink};
pub struct LocalFs;
#[wrap_err]
#[derive(Debug, thiserror::Error)]
pub enum LocalFsSinkError {
#[foreign]
Io(std::io::Error),
#[error(transparent)]
Path(
#[from]
#[backtrace]
LocalPathError,
),
}
fn try_remove_file(path: &Path) -> Result<(), std::io::Error> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
impl Sink<LocalFs> for Issue {
type Error = LocalFsSinkError;
async fn sink(&mut self, old: Option<&Issue>) -> Result<bool, Self::Error> {
let result = sink_issue_node(self, old, &FsReader)?;
if matches!(self.contents.blockers.set_state, Some(crate::BlockerSetState::Pending)) {
let title = &self.contents.title;
let closed = self.contents.state.is_closed();
let has_children = !self.children.is_empty();
match LocalPath::from(&*self).resolve_parent(FsReader) {
Ok(resolved) => {
let issue_file_path = resolved.deterministic(title, closed, has_children).path();
self.contents.blockers.ensure_set(&issue_file_path);
}
Err(e) => {
tracing::warn!("!s: failed to resolve issue path for blocker selection: {e}");
}
}
}
Ok(result)
}
}
#[instrument(skip_all, fields(
issue_id = ?new.git_id(),
title = %new.contents.title,
))]
fn sink_issue_node<R: LocalReader>(new: &Issue, maybe_old: Option<&Issue>, reader: &R) -> Result<bool, LocalFsSinkError> {
if let crate::CloseState::Duplicate(dup_of) = new.contents.state {
info!(issue = ?new.git_id(), duplicate_of = dup_of, "Removing duplicate issue from local storage");
return remove_issue_files(new, reader);
}
let title = &new.contents.title;
let closed = new.contents.state.is_closed();
let has_children = !new.children.is_empty();
let old_has_children = maybe_old.map(|o| !o.children.is_empty()).unwrap_or(false); let format_changed = has_children != old_has_children;
let owner = new.identity.owner().to_string();
let repo = new.identity.repo().to_string();
let issue_file_path = LocalPath::from(new).resolve_parent(*reader)?.deterministic(title, closed, has_children).path();
debug!(issue_file_path = %issue_file_path.display(), "computed target path");
let content = new.serialize_filesystem(); let node_changed = match maybe_old {
Some(old_issue) => content != old_issue.serialize_filesystem() || format_changed, None => true,
};
let mut any_written = false;
if node_changed {
std::fs::create_dir_all(issue_file_path.parent().unwrap())?;
std::fs::write(&issue_file_path, &content)?;
any_written = true;
}
let should_cleanup: bool = {
let title_or_state_or_id_changed: bool = {
match maybe_old {
Some(old) => {
let title_changed = title != &old.contents.title;
let state_changed = new.contents.state != old.contents.state;
let id_changed = new.git_id() != old.git_id();
title_changed || state_changed || id_changed
}
None => true,
}
};
format_changed | title_or_state_or_id_changed
};
if should_cleanup && reader.is_mutable() {
cleanup_old_locations(new, has_children, closed)?;
}
if let Some(old) = maybe_old
&& old.identity.parent_index != new.identity.parent_index
&& reader.is_mutable()
&& let Ok(old_path) = LocalPath::from(old).resolve_parent(*reader)
{
let old_location = old_path.deterministic(&old.contents.title, old.contents.state.is_closed(), !old.children.is_empty()).path();
if old_location.is_dir() {
let _ = std::fs::remove_dir_all(&old_location);
} else if old_location.is_file() {
let _ = std::fs::remove_file(&old_location);
}
}
if let Some(issue_num) = new.git_id()
&& let Some(timestamps) = new.identity.timestamps()
{
let meta = IssueMeta {
user: new.user().map(str::to_owned),
timestamps: timestamps.clone(),
};
Local::save_issue_meta(RepoInfo::new(&owner, &repo), issue_num, &meta)?;
}
let old_children: HashMap<_, _> = maybe_old.map(|o| o.children.iter().collect()).unwrap_or_default();
for (selector, child) in &new.children {
let old_child = old_children.get(selector).copied();
any_written |= sink_issue_node(child, old_child, reader)?;
}
for (selector, old_child) in &old_children {
if !new.children.contains_key(selector) {
remove_issue_files(old_child, reader)?;
if let Some(old_num) = old_child.git_id() {
Local::remove_issue_meta(RepoInfo::new(&owner, &repo), old_num)?;
}
}
}
Ok(any_written)
}
#[instrument(skip(issue), fields(has_children, closed))]
fn cleanup_old_locations(issue: &Issue, has_children: bool, closed: bool) -> Result<(), LocalFsSinkError> {
let parent_issue_idx = issue.parent_index();
let parent_path = LocalPath::from(parent_issue_idx);
let reader = FsReader;
let resolved_parent_pwd = match parent_path.resolve_parent(reader) {
Ok(r) => r,
Err(e) if matches!(e.kind, LocalPathErrorKind::MissingParent | LocalPathErrorKind::NotFound) => {
debug!("parent missing or not found, - nothing to clean");
return Ok(());
}
Err(e) => return Err(e.into()),
};
fn is_main_file(path: &Path) -> bool {
path.file_name().unwrap().to_string_lossy().starts_with(Local::MAIN_ISSUE_FILENAME) }
match resolved_parent_pwd.matching_subpaths() {
Ok(matches_for_parent) => {
let misplaced_standalone_files: Vec<PathBuf> = matches_for_parent.into_iter().filter(|p| !p.is_dir() && !is_main_file(p)).collect();
match misplaced_standalone_files.len() {
0 => debug!("no files to cleanup for parent"),
1 => {
let standalone = misplaced_standalone_files.into_iter().next().unwrap();
let name = standalone.file_name().unwrap().to_string_lossy();
let is_closed = name.ends_with(".bak");
let dirname = format!("{}/", name.strip_suffix(".md.bak").or_else(|| name.strip_suffix(".md")).unwrap());
let main_file_path = Local::main_file_path(&standalone.parent().unwrap().join(dirname), is_closed);
std::fs::create_dir_all(main_file_path.parent().unwrap())?;
std::fs::rename(standalone, main_file_path)?;
}
_ => todo!("should have a good error here"),
}
}
Err(e) if e.kind == LocalPathErrorKind::NotFound => {
debug!("no trace of parent's dir or old issue files, - safe to assume issue doesn't exist either; nothing to clean");
return Ok(());
}
Err(e) => return Err(e.into()),
}
let resolved_parent_dir = LocalPath::from(issue).resolve_parent(reader)?; let matching = resolved_parent_dir.matching_subpaths()?;
let title = &issue.contents.title;
let target = resolved_parent_dir.deterministic(title, closed, has_children).path();
for path in matching {
match path == target {
true => {
trace!(path = %path.display(), "path matches target, keeping");
}
false => {
debug!(path = %path.display(), "path differs from target, will remove");
match is_main_file(&path) {
true => {
let parent_dir = path.parent().unwrap();
info!(parent_dir = %parent_dir.display(), "removing directory (converting to flat format)");
std::fs::remove_dir_all(parent_dir)?;
}
false => {
info!(path = %path.display(), "removing old file location");
try_remove_file(&path)?;
}
}
}
}
}
if issue.git_id().is_some() {
use crate::IssueSelector;
let mut title_index = issue.identity.parent_index;
title_index.push(IssueSelector::title(title));
let title_path = LocalPath::new(title_index);
if let Ok(title_resolved) = title_path.resolve_parent(reader)
&& let Ok(title_matching) = title_resolved.matching_subpaths()
{
for path in title_matching {
if path != target {
debug!(path = %path.display(), "removing old title-based path");
if is_main_file(&path) {
if let Some(parent_dir) = path.parent() {
let _ = std::fs::remove_dir_all(parent_dir);
}
} else {
let _ = try_remove_file(&path);
}
}
}
}
}
Ok(())
}
#[instrument(skip_all)]
fn remove_issue_files<R: LocalReader>(issue: &Issue, reader: &R) -> Result<bool, LocalFsSinkError> {
let owner = issue.identity.owner().to_string();
let repo = issue.identity.repo().to_string();
match LocalPath::from(issue).resolve_parent(*reader)?.search() {
Ok(resolved_path) =>
if let Some(dir) = resolved_path.clone().issue_dir() {
info!(dir = %dir.display(), "removing issue directory");
std::fs::remove_dir_all(dir)?;
} else {
let p = resolved_path.path();
info!(path = %p.display(), "removing issue file");
try_remove_file(&p)?;
},
Err(e) if e.kind == LocalPathErrorKind::NotFound => {
debug!("issue not found, nothing to remove");
}
Err(e) => return Err(e.into()),
};
if let Some(num) = issue.git_id() {
trace!(num, "removing issue metadata");
Local::remove_issue_meta(RepoInfo::new(&owner, &repo), num)?;
}
info!(issue_number = ?issue.git_id(), "removed issue");
Ok(true)
}