1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
//! Pure repository proposals for normalized logbook form entries.
use super::{FileChange, FileChangeKind, RepositoryChangeSet, WorkflowFileReport};
use crate::io::ApiResult;
use acorn_core::{
prelude::{String, Vec},
util::MimeType,
};
use acorn_host::fs::SafePath;
use acorn_schema::{
research_activity::{Logbook, LogbookEntry, ResearchActivity},
validation::Validate,
};
use color_eyre::eyre::eyre;
/// Immutable input for one logbook repository proposal.
#[derive(Clone, Debug)]
pub struct LogbookProposal {
/// Exact source revision read by the caller.
pub base_revision: String,
/// Normalized research activity JSON read at `base_revision`.
pub content: String,
/// Validated entry proposed for the activity logbook.
pub entry: LogbookEntry,
/// Repository-relative research activity path.
pub path: String,
}
impl TryFrom<LogbookProposal> for RepositoryChangeSet {
type Error = color_eyre::eyre::Report;
/// Append a missing entry, accept an identical replay, or reject an identifier conflict.
fn try_from(proposal: LogbookProposal) -> ApiResult<Self> {
let LogbookProposal {
base_revision,
content,
entry,
path,
} = proposal;
match (base_revision.trim().is_empty(), SafePath::new(&path)) {
| (true, _) => Err(eyre!("Repository revision is required")),
| (_, Err(why)) => Err(eyre!(why)),
| (false, Ok(safe_path)) => entry
.validate()
.map_err(|why| eyre!("Invalid logbook entry — {why}"))
.and_then(|()| {
eserde::json::from_str::<ResearchActivity>(&content).map_err(|why| eyre!("Failed to parse research activity JSON — {why}"))
})
.and_then(|activity| {
activity
.validate()
.map_err(|why| eyre!("Invalid research activity data — {why}"))
.map(|()| activity)
})
.and_then(|activity| {
let existing = activity
.logbook
.as_ref()
.into_iter()
.flat_map(|logbook| &logbook.entries)
.find(|candidate| candidate.identifier == entry.identifier);
match existing {
| Some(existing) => serde_json::to_value(existing)
.and_then(|existing| serde_json::to_value(&entry).map(|entry| existing == entry))
.map_err(|why| eyre!("Failed to compare logbook replay — {why}"))
.and_then(|identical| match identical {
| true => Ok((activity, false)),
| false => Err(eyre!("Logbook entry identifier '{}' already has different content", entry.identifier)),
}),
| None => {
let logbook = match activity.logbook {
| Some(logbook) => Logbook {
entries: logbook.entries.into_iter().chain(core::iter::once(entry)).collect(),
..logbook
},
| None => Logbook::init().entries(vec![entry]).build(),
};
Ok((
ResearchActivity {
logbook: Some(logbook),
..activity
},
true,
))
}
}
})
.and_then(|(activity, changed)| {
let path = safe_path.as_path().to_string_lossy().to_string();
match changed {
| false => Ok(RepositoryChangeSet {
base_revision,
files: Vec::new(),
reports: vec![WorkflowFileReport {
changed: false,
format: MimeType::Json,
path,
}],
}),
| true => serde_json::to_string_pretty(&activity)
.map_err(|why| eyre!("Failed to serialize research activity JSON — {why}"))
.map(|content| RepositoryChangeSet {
base_revision,
files: vec![FileChange {
content: format!("{content}\n"),
kind: FileChangeKind::Update,
path: path.clone(),
}],
reports: vec![WorkflowFileReport {
changed: true,
format: MimeType::Json,
path,
}],
}),
}
}),
}
}
}