1use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Describe {
15 pub name: String,
17 pub link_namespace: String,
20 pub fetch_style: FetchStyle,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub auth_realm: Option<String>,
26 pub protocol: u32,
35}
36
37pub const PROTOCOL: u32 = 1;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42pub enum FetchStyle {
43 Windowed,
45 Snapshot,
47 Sweep,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct AuthChallenge {
56 pub verification_url: String,
57 pub user_code: String,
58 pub expires_in_secs: u64,
59 pub handle: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub enum AuthPollResult {
64 Pending,
65 Done(String),
67 Failed(String),
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub enum AuthStatus {
72 NotRequired,
73 Authenticated(String),
75 NotAuthenticated(String),
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct Context {
83 pub config: String,
85 pub secrets_dir: PathBuf,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct FetchRequest {
95 pub config: String,
96 pub secrets_dir: PathBuf,
97 pub out_dir: PathBuf,
99 pub spec: RunSpec,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub checkpoint: Option<String>,
103}
104
105#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108pub struct FetchResult {
109 pub items: Vec<Item>,
110 #[serde(default, skip_serializing_if = "String::is_empty")]
111 pub notes: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub next_checkpoint: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub enum RunSpec {
119 Window {
121 from: String,
122 to: String,
123 },
124 Snapshot,
125 Sweep,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct Item {
134 pub id: String,
136 #[serde(default, skip_serializing_if = "String::is_empty")]
139 pub kind: String,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub version: Option<String>,
143 #[serde(default, skip_serializing_if = "String::is_empty")]
148 pub content_hash: String,
149 pub files: Vec<String>,
151 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
160 pub file_hashes: std::collections::BTreeMap<String, String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub locator: Option<String>,
165 #[serde(default, skip_serializing_if = "String::is_empty")]
167 pub meta: String,
168}
169
170impl Item {
171 pub fn new(
176 kind: impl Into<String>,
177 id: impl Into<String>,
178 content_hash: impl Into<String>,
179 files: impl IntoIterator<Item = impl Into<String>>,
180 ) -> Item {
181 Item {
182 id: id.into(),
183 kind: kind.into(),
184 version: None,
185 content_hash: content_hash.into(),
186 files: files.into_iter().map(Into::into).collect(),
187 file_hashes: std::collections::BTreeMap::new(),
188 locator: None,
189 meta: String::new(),
190 }
191 }
192
193 pub fn with_version(mut self, version: impl Into<String>) -> Item {
194 self.version = Some(version.into());
195 self
196 }
197
198 pub fn with_locator(mut self, locator: impl Into<String>) -> Item {
199 self.locator = Some(locator.into());
200 self
201 }
202
203 pub fn with_meta(mut self, meta: impl Into<String>) -> Item {
204 self.meta = meta.into();
205 self
206 }
207
208 pub fn with_file_hash(mut self, path: impl Into<String>, digest: impl Into<String>) -> Item {
211 self.file_hashes.insert(path.into(), digest.into());
212 self
213 }
214
215 pub fn version_fingerprint(&self) -> String {
226 version_fingerprint(&self.content_hash, &self.file_hashes)
227 }
228}
229
230pub fn version_fingerprint(
233 content_hash: &str,
234 file_hashes: &std::collections::BTreeMap<String, String>,
235) -> String {
236 if file_hashes.is_empty() {
237 return content_hash.to_string();
238 }
239 use sha2::Digest;
240 let mut h = sha2::Sha256::new();
241 h.update(content_hash.as_bytes());
242 for (path, digest) in file_hashes {
243 h.update([0u8]);
244 h.update(path.as_bytes());
245 h.update([0u8]);
246 h.update(digest.as_bytes());
247 }
248 format!("{:x}", h.finalize())
249}
250
251pub trait SourcePlugin {
254 fn describe(&self) -> Describe;
255 fn validate_config(&self, config: &str) -> Result<(), String>;
258 fn config_auth_realm(&self, _config: &str) -> Result<Option<String>, String> {
261 Ok(None)
262 }
263 fn auth_status(&self, ctx: &Context) -> Result<AuthStatus, String>;
264 fn authenticate(&self, ctx: &Context) -> Result<(), String>;
265 fn auth_begin(&self, _ctx: &Context) -> Result<AuthChallenge, String> {
269 Err("interactive sign-in is not supported for this source — use the CLI".into())
270 }
271 fn auth_poll(&self, _ctx: &Context, _handle: &str) -> Result<AuthPollResult, String> {
273 Err("interactive sign-in is not supported for this source".into())
274 }
275 fn fetch(&self, req: &FetchRequest) -> Result<FetchResult, String>;
276}
277
278#[derive(Debug, Serialize, Deserialize)]
285pub enum PluginRequest {
286 Describe,
287 ValidateConfig { config: String },
288 ConfigAuthRealm { config: String },
289 AuthStatus { ctx: Context },
290 Authenticate { ctx: Context },
291 AuthBegin { ctx: Context },
292 AuthPoll { ctx: Context, handle: String },
293 Fetch { req: FetchRequest },
294}
295
296#[derive(Debug, Serialize, Deserialize)]
297pub enum PluginResponse {
298 Describe(Describe),
299 Ok,
300 Realm(Option<String>),
301 Status(AuthStatus),
302 Challenge(AuthChallenge),
303 Poll(AuthPollResult),
304 Fetched(FetchResult),
305 Error(String),
307}
308
309pub fn tap_main(select: impl Fn(&str) -> Option<Box<dyn SourcePlugin>>) {
313 use std::io::Read;
314 let respond = |r: &PluginResponse| match crate::ronfmt::to_ron(r) {
315 Ok(text) => print!("{text}"),
316 Err(e) => {
317 eprintln!("serializing response: {e}");
318 std::process::exit(2);
319 }
320 };
321 let args: Vec<String> = std::env::args().collect();
322 let name = match args.iter().position(|a| a == "--plugin") {
323 Some(i) if i + 1 < args.len() => args[i + 1].clone(),
324 _ => {
325 respond(&PluginResponse::Error("usage: --plugin <name>".into()));
326 std::process::exit(2);
327 }
328 };
329 let Some(plugin) = select(&name) else {
330 respond(&PluginResponse::Error(format!(
331 "this tap does not serve a plugin named '{name}'"
332 )));
333 std::process::exit(2);
334 };
335 let mut input = String::new();
336 if let Err(e) = std::io::stdin().read_to_string(&mut input) {
337 respond(&PluginResponse::Error(format!("reading stdin: {e}")));
338 std::process::exit(2);
339 }
340 let request = match ron::from_str::<PluginRequest>(&input) {
341 Ok(r) => r,
342 Err(e) => {
343 respond(&PluginResponse::Error(format!("parsing request: {e}")));
344 std::process::exit(2);
345 }
346 };
347 let response = match request {
348 PluginRequest::Describe => PluginResponse::Describe(plugin.describe()),
349 PluginRequest::ValidateConfig { config } => match plugin.validate_config(&config) {
350 Ok(()) => PluginResponse::Ok,
351 Err(e) => PluginResponse::Error(e),
352 },
353 PluginRequest::ConfigAuthRealm { config } => match plugin.config_auth_realm(&config) {
354 Ok(r) => PluginResponse::Realm(r),
355 Err(e) => PluginResponse::Error(e),
356 },
357 PluginRequest::AuthStatus { ctx } => match plugin.auth_status(&ctx) {
358 Ok(s) => PluginResponse::Status(s),
359 Err(e) => PluginResponse::Error(e),
360 },
361 PluginRequest::Authenticate { ctx } => match plugin.authenticate(&ctx) {
362 Ok(()) => PluginResponse::Ok,
363 Err(e) => PluginResponse::Error(e),
364 },
365 PluginRequest::AuthBegin { ctx } => match plugin.auth_begin(&ctx) {
366 Ok(c) => PluginResponse::Challenge(c),
367 Err(e) => PluginResponse::Error(e),
368 },
369 PluginRequest::AuthPoll { ctx, handle } => match plugin.auth_poll(&ctx, &handle) {
370 Ok(p) => PluginResponse::Poll(p),
371 Err(e) => PluginResponse::Error(e),
372 },
373 PluginRequest::Fetch { req } => match plugin.fetch(&req) {
374 Ok(r) => PluginResponse::Fetched(r),
375 Err(e) => PluginResponse::Error(e),
376 },
377 };
378 respond(&response);
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn item_builder_keeps_required_and_optional_wire_fields_explicit() {
387 let item = Item::new("meeting", "m1", "primary", ["record.json", "notes.vtt"])
388 .with_version("etag-1")
389 .with_locator("m1")
390 .with_meta("Planning")
391 .with_file_hash("notes.vtt", "secondary");
392
393 assert_eq!(item.kind, "meeting");
394 assert_eq!(item.id, "m1");
395 assert_eq!(item.content_hash, "primary");
396 assert_eq!(item.files, ["record.json", "notes.vtt"]);
397 assert_eq!(item.version.as_deref(), Some("etag-1"));
398 assert_eq!(item.locator.as_deref(), Some("m1"));
399 assert_eq!(item.meta, "Planning");
400 assert_eq!(item.file_hashes["notes.vtt"], "secondary");
401 }
402}