use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use super::reading::{Group, Observation, Snapshot};
use crate::config::UserConfig;
pub(crate) const READY_KIND: &str = "Ready";
#[derive(Clone, Deserialize, Serialize)]
pub(crate) struct DismissedRow {
pub selector: String,
pub kind: String,
#[serde(default)]
pub kinds: Vec<String>,
pub who: String,
pub at: String,
#[serde(default)]
pub from_console: bool,
}
impl DismissedRow {
pub(crate) fn covers(&self, kind: &str) -> bool {
self.kinds.iter().any(|covered| covered == kind)
}
fn source(&self) -> &'static str {
if self.from_console { "Console" } else { "CLI" }
}
pub(crate) fn view(&self, current_kind: Option<&str>) -> DismissalView {
DismissalView {
who: self.who.clone(),
dismissed_at: self.at.clone(),
source: self.source(),
kind: current_kind.unwrap_or(&self.kind).to_owned(),
kinds: self.kinds.clone(),
}
}
}
#[derive(Serialize)]
pub(crate) struct DismissalView {
pub who: String,
pub dismissed_at: String,
pub source: &'static str,
pub kind: String,
pub kinds: Vec<String>,
}
impl DismissalView {
pub(crate) fn line(&self) -> String {
format!(
"dismissed by {} from the {} at {}",
self.who, self.source, self.dismissed_at
)
}
}
#[derive(Default, Deserialize, Serialize)]
struct DismissalFile {
#[serde(default)]
rows: Vec<DismissedRow>,
}
fn dismissal_path(record: &Path) -> PathBuf {
record.with_extension("dismiss.json")
}
fn read(record: &Path) -> DismissalFile {
std::fs::read(dismissal_path(record))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
pub(crate) fn covering(record: &Path, selector: &str) -> Option<DismissedRow> {
read(record)
.rows
.into_iter()
.find(|row| row.selector == selector)
.map(|mut row| {
if !row.covers(&row.kind) {
row.kinds.push(row.kind.clone());
}
row
})
}
fn write(record: &Path, file: &DismissalFile) -> Result<()> {
let path = dismissal_path(record);
if file.rows.is_empty() {
return match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).with_context(|| {
format!("could not remove the dismissal beside {}", record.display())
}),
};
}
let parent = path.parent().context("Command record has no directory")?;
let mut staged = tempfile::NamedTempFile::new_in(parent)?;
staged.write_all(&serde_json::to_vec(&file)?)?;
staged
.persist(&path)
.map(|_| ())
.with_context(|| format!("could not keep the dismissal beside {}", record.display()))
}
struct DismissalTarget {
repository: String,
name: String,
subject: String,
group: Group,
kind: String,
members: Vec<(PathBuf, String)>,
dismissed: bool,
}
fn row_for(
snapshot: &Snapshot,
github: &super::github::Kept,
input: &str,
days: i64,
undo: bool,
) -> Result<DismissalTarget> {
let selected = snapshot.resolve(input)?;
let record = match selected.observation {
Observation::Record(command) => &command.record,
Observation::History(_) => {
bail!("cannot dismiss {input}: row has no Command record");
}
};
let name = record.name.clone();
let mut target = DismissalTarget {
repository: record.repository.clone(),
subject: match &record.issue {
Some(issue) => format!("{} #{}", record.kind, issue.number),
None => name.clone(),
},
name,
group: Group::History,
kind: String::new(),
members: Vec::new(),
dismissed: false,
};
let selector = selected
.observation
.selector()
.with_context(|| format!("cannot dismiss {input}: row has no Command record"))?;
let found = snapshot
.inbox(true, days, github)
.into_iter()
.find(|item| {
item.command.selector().as_deref() == Some(selector.as_str())
|| item
.collapsed
.iter()
.any(|child| child.command.selector().as_deref() == Some(selector.as_str()))
})
.with_context(|| {
format!("cannot dismiss {input}: row is not in Needs you or Ready for review")
})?;
for item in std::iter::once(&found).chain(found.collapsed.iter()) {
let Observation::Record(command) = item.command else {
bail!("cannot dismiss {input}: row has no Command record");
};
let selector = item
.command
.selector()
.with_context(|| format!("cannot dismiss {input}: row has no Command record"))?;
target.members.push((command.record_path.clone(), selector));
}
let kind = match found.group {
Group::NeedsYou => Some(
found
.needs_you_kind
.map(|kind| kind.title().to_owned())
.with_context(|| format!("cannot dismiss {input}: row has no Needs you kind"))?,
),
Group::ReadyForReview => Some(READY_KIND.to_owned()),
_ => None,
};
target.kind = match kind {
Some(kind) => kind,
None if undo => String::new(),
None => {
bail!(
"cannot dismiss {input}: row is in {}, not Needs you or Ready for review",
found.group.title()
);
}
};
target.group = found.group;
target.dismissed = found.dismissal.is_some();
Ok(target)
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Action {
Dismiss,
Undo,
}
pub(super) fn action(
snapshot: &Snapshot,
github: &super::github::Kept,
input: &str,
days: i64,
) -> Option<Action> {
let target = row_for(snapshot, github, input, days, true).ok()?;
if matches!(target.group, Group::NeedsYou | Group::ReadyForReview) {
Some(Action::Dismiss)
} else if target.dismissed {
Some(Action::Undo)
} else {
None
}
}
pub(crate) struct Request {
pub who: String,
pub undo: bool,
pub from_console: bool,
}
pub(crate) struct Change {
name: String,
group: Group,
dismissal: Option<DismissalView>,
}
fn activity_write(config: &UserConfig, repository: &str, line: &str) -> Result<()> {
let path = config.logs_dir.join(repository).join(super::activity::FILE);
let mut file = super::activity::open(&path)?;
writeln!(
file,
"{} {line}",
chrono::Local::now().format(super::activity::TIME_FORMAT)
)
.with_context(|| format!("could not keep {}", path.display()))
}
pub fn dismiss(input: &str, undo: bool, json: bool) -> Result<()> {
let who = super::unix_user("dismissal")?;
let change = request(
input,
Request {
who,
undo,
from_console: false,
},
)?;
if let Some(dismissal) = change.dismissal {
if json {
println!(
"{}",
serde_json::json!({
"version": 1, "name": change.name, "group": change.group,
"dismissal": dismissal,
})
);
} else {
println!("dismissed {} from {}", change.name, change.group.title());
}
} else if json {
println!(
"{}",
serde_json::json!({"version": 1, "name": change.name, "undone": true})
);
} else {
println!("took back the dismissal of {}", change.name);
}
Ok(())
}
pub(crate) fn request(input: &str, request: Request) -> Result<Change> {
let Request {
who,
undo,
from_console,
} = request;
let source = if from_console { "Console" } else { "CLI" };
let at = chrono::Local::now().to_rfc3339();
let config = UserConfig::load()?;
let snapshot = Snapshot::load_with_silence(
&config.logs_dir,
chrono::Local::now(),
config.silent_interval,
)?;
let github = super::github::load(&config.logs_dir);
let target = row_for(&snapshot, &github, input, config.inbox_days, undo)?;
if target.members.is_empty() {
bail!("cannot dismiss {input}: row has no Command record");
}
if undo {
let mut taken_back = false;
for (record, selector) in &target.members {
let mut file = read(record);
let kept = file.rows.len();
file.rows.retain(|row| row.selector != *selector);
if file.rows.len() != kept {
taken_back = true;
write(record, &file)?;
}
}
if !taken_back {
bail!("cannot take back the dismissal of {input}: row is not dismissed");
}
activity_write(
&config,
&target.repository,
&format!(
"{} dismissal taken back from the {source} by {who}",
target.subject
),
)?;
return Ok(Change {
name: target.name,
group: target.group,
dismissal: None,
});
}
let mut dismissal = None;
for (record, selector) in &target.members {
let mut file = read(record);
let mut kinds = covering(record, selector)
.map(|row| row.kinds)
.unwrap_or_default();
if !kinds.contains(&target.kind) {
kinds.push(target.kind.clone());
}
file.rows.retain(|row| row.selector != *selector);
let row = DismissedRow {
selector: selector.clone(),
kind: target.kind.clone(),
kinds,
who: who.clone(),
at: at.clone(),
from_console,
};
dismissal.get_or_insert_with(|| row.view(None));
file.rows.push(row);
write(record, &file)?;
}
activity_write(
&config,
&target.repository,
&format!("{} dismissed from the {source} by {who}", target.subject),
)?;
Ok(Change {
name: target.name,
group: target.group,
dismissal,
})
}