1use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize};
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8use haematite::branch::KeptLimit;
9use haematite::db::DatabaseConfig;
10use haematite::store::NodeCacheBudget;
11use serde::Deserialize;
12
13use super::{
14 changes, embed,
15 live::LysLive,
16 pass::Gate,
17 placement,
18 query::{Options, Setting, Settings, probe},
19 releaser,
20 service::Service,
21 storage::{KEPT_VERSIONS_DEFAULT, KEPT_VERSIONS_MOST, Storage, UndatedGeneration},
22 transport::{Limits, Listener},
23};
24
25pub fn name_is_live(default_endpoint: &std::path::Path, expected_uid: u32) -> Result<bool, String> {
35 super::socket_owner::name_is_live(default_endpoint, expected_uid).map_err(|error| {
36 format!(
37 "name_owner_probe_failed: {}: {error}",
38 default_endpoint.display()
39 )
40 })
41}
42
43const USAGE: &str = "usage: haematite serve --config <file.json> [--create] [--long-wait-interval <duration>]\n\
44--create requires a new database directory; omit it when restarting.\n\
45An explicit socket's parent directory must already exist with mode 0700.\n\
46An omitted socket uses the private OS runtime namespace. No auto-launch is installed.";
47
48const DEFAULT_LONG_WAIT_INTERVAL: Duration = Duration::from_secs(30);
49
50struct ServeOptions {
51 path: PathBuf,
52 create: bool,
53 long_wait_interval: Duration,
54}
55
56fn long_wait_help() -> String {
57 format!(
58 "--long-wait-interval <duration> is the interval between long-wait warnings, not a deadline. \
59 default: {}s; use an integer followed by ns, us, ms, s, m or h.",
60 DEFAULT_LONG_WAIT_INTERVAL.as_secs()
61 )
62}
63
64#[derive(Debug, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct Config {
67 pub mcp_http: Option<crate::mcp::http::Config>,
68 pub data_dir: PathBuf,
69 pub socket: Option<PathBuf>,
70 pub name: Option<String>,
71 pub branch_cache_bytes_per_shard: NonZeroUsize,
72 pub max_frame: NonZeroU32,
73 pub max_clients: NonZeroUsize,
74 pub max_page: NonZeroU32,
75 #[serde(default = "append_prune_keep_ceiling")]
76 pub append_prune_keep_ceiling: u32,
77 #[serde(default = "append_prune_removal_cap")]
78 pub append_prune_removal_cap: u32,
79 pub timeout_ms: NonZeroU64,
80 pub create: Option<Creation>,
81 #[serde(default = "changes_scan_records")]
83 pub changes_scan_records: NonZeroU64,
84 #[serde(default = "changes_node_budget")]
85 pub changes_node_budget: NonZeroUsize,
86 #[serde(default = "changes_max_page")]
87 pub changes_max_page: NonZeroU32,
88 #[serde(default, deserialize_with = "super::query::settings::time_ms")]
89 pub query_time_ms: Setting<u64>,
90 #[serde(default, deserialize_with = "super::query::settings::memory_bytes")]
91 pub query_memory_bytes: Setting<u64>,
92 #[serde(
93 default,
94 deserialize_with = "super::query::settings::outputs_per_record"
95 )]
96 pub query_outputs_per_record: Setting<u64>,
97 #[serde(default, deserialize_with = "super::query::settings::scan_records")]
98 pub query_scan_records: Setting<u64>,
99 #[serde(default, deserialize_with = "super::query::settings::max_running")]
100 pub query_max_running: Setting<u64>,
101 #[serde(default, deserialize_with = "super::query::settings::worker")]
102 pub query_worker: Setting<PathBuf>,
103 #[serde(default)]
104 pub embed_worker: Setting<PathBuf>,
105 #[serde(default)]
106 pub embed_model_dir: Setting<PathBuf>,
107 #[serde(default)]
108 pub embed_model_sha256: Setting<String>,
109 #[serde(default)]
110 pub embed_memory_bytes: Setting<u64>,
111 #[serde(default)]
112 pub embed_threads: Setting<u64>,
113 #[serde(default)]
114 pub embed_batch_items: Setting<u64>,
115 #[serde(default)]
116 pub embed_input_bytes: Setting<u64>,
117 #[serde(default)]
118 pub embed_tokens: Setting<u64>,
119 #[serde(default)]
120 pub embed_frame_bytes: Setting<u32>,
121 #[serde(default)]
122 pub embed_ready_ms: Setting<u64>,
123 #[serde(default)]
124 pub embed_call_ms: Setting<u64>,
125 #[serde(default)]
126 pub embed_waiting: Setting<u64>,
127 #[serde(default)]
128 pub embed_request_items: Setting<u64>,
129 #[serde(default)]
130 pub embed_page_records: Setting<u64>,
131 #[serde(default)]
132 pub embed_writer_wait_ms: Setting<u64>,
133 #[serde(default)]
135 pub embed_windows: Setting<bool>,
136 #[serde(default)]
138 pub embed_max_windows: Setting<u64>,
139 #[serde(default)]
141 pub embed_window_overlap_tokens: Setting<u64>,
142 #[serde(default)]
145 pub embed_restart_ms: Setting<u64>,
146 #[serde(default)]
149 pub embed_restart_most_ms: Setting<u64>,
150 #[serde(default)]
155 pub embed_question_worker: Setting<bool>,
156 #[serde(default = "kept_versions", deserialize_with = "kept_versions_within")]
159 pub kept_versions: usize,
160 #[serde(default)]
162 pub retention: releaser::Config,
163 #[serde(default)]
167 pub lys: Option<LysConfig>,
168}
169
170#[derive(Debug, Deserialize)]
173#[serde(deny_unknown_fields)]
174pub struct LysConfig {
175 pub issuer: String,
178 pub database: String,
180 #[serde(default)]
187 pub placement_credential_file: Option<PathBuf>,
188}
189
190fn lys_parts(lys: Option<&LysConfig>) -> Result<(Option<Gate>, Option<placement::Placer>), String> {
196 let gate = lys.map(lys_gate).transpose()?;
197 let placer = lys
198 .and_then(|lys| {
199 lys.placement_credential_file.as_deref().map(|file| {
200 placement::Lys::new(&lys.issuer, &lys.database, file)
201 .and_then(placement::Placer::start)
202 })
203 })
204 .transpose()?;
205 Ok((gate, placer))
206}
207
208fn served_through_lys(
211 mut service: Service,
212 gate: Option<Gate>,
213 placer: Option<placement::Placer>,
214) -> Result<Service, String> {
215 if let Some(gate) = gate {
216 service = service.with_lys(gate);
217 }
218 if let Some(placer) = placer {
219 service = service
220 .with_placer(placer)
221 .map_err(|error| error.to_string())?;
222 }
223 Ok(service)
224}
225
226fn lys_gate(config: &LysConfig) -> Result<Gate, String> {
233 if config.database.is_empty()
234 || !config
235 .database
236 .bytes()
237 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
238 {
239 return Err(format!(
240 "lys database id {:?} must be letters, digits, _ or - only",
241 config.database
242 ));
243 }
244 let mut live = LysLive::new(&config.issuer)
245 .map_err(|error| format!("{}: the lys issuer {}", error.name(), config.issuer))?;
246 if let Some(file) = config.placement_credential_file.as_deref() {
247 live = live.asking_as(super::lys_schema::read_credential(file)?);
248 }
249 let live = Arc::new(live);
250 let asking = Arc::clone(&live);
251 Gate::fetching(
252 move || asking.keys(),
253 &config.issuer,
254 &config.database,
255 live,
256 )
257 .map_err(|error| format!("the thread reading Lys's keys could not start: {error}"))
258}
259
260const fn kept_versions() -> usize {
261 KEPT_VERSIONS_DEFAULT
262}
263
264fn kept_versions_within<'de, D>(deserializer: D) -> Result<usize, D::Error>
266where
267 D: serde::Deserializer<'de>,
268{
269 let value = u64::deserialize(deserializer)?;
270 usize::try_from(value)
271 .ok()
272 .filter(|kept| (1..=KEPT_VERSIONS_MOST).contains(kept))
273 .ok_or_else(|| {
274 serde::de::Error::custom(format!(
275 "kept_versions must be a whole number from 1 to {KEPT_VERSIONS_MOST}, not {value}"
276 ))
277 })
278}
279
280const fn append_prune_keep_ceiling() -> u32 {
281 4_096
282}
283
284const fn append_prune_removal_cap() -> u32 {
285 64
286}
287
288fn changes_scan_records() -> NonZeroU64 {
289 changes::Settings::default().scan_records
290}
291
292fn changes_node_budget() -> NonZeroUsize {
293 changes::Settings::default().node_budget
294}
295
296fn changes_max_page() -> NonZeroU32 {
297 changes::Settings::default().max_page
298}
299
300impl Config {
301 pub fn endpoint(
306 &self,
307 create: bool,
308 ) -> Result<super::runtime::Endpoint, super::runtime::Refusal> {
309 #[cfg(test)]
310 if let Some((root, uid)) = endpoint_tests::input() {
311 return super::runtime::resolve_input(
312 &self.data_dir,
313 self.name.as_deref(),
314 self.socket.as_deref(),
315 create,
316 &root,
317 uid,
318 );
319 }
320 super::runtime::resolve(
321 &self.data_dir,
322 self.name.as_deref(),
323 self.socket.as_deref(),
324 create,
325 )
326 }
327
328 pub fn kept_limit(&self) -> Result<KeptLimit, String> {
333 KeptLimit::new(self.kept_versions).map_err(|error| error.to_string())
334 }
335
336 pub fn append_prune_limits(&self) -> Result<super::storage::AppendPruneLimits, String> {
339 super::storage::AppendPruneLimits::new(
340 self.append_prune_keep_ceiling,
341 self.append_prune_removal_cap,
342 )
343 .map_err(|failure| failure.to_string())
344 }
345
346 pub fn changes_settings(&self) -> Result<changes::Settings, String> {
351 if self.changes_scan_records.get() < 2 {
352 return Err("changes_scan_records must be at least 2".to_owned());
353 }
354 Ok(changes::Settings {
355 scan_records: self.changes_scan_records,
356 node_budget: self.changes_node_budget,
357 max_page: self.changes_max_page,
358 })
359 }
360
361 pub fn query_settings(&self) -> Result<Option<Settings>, String> {
366 Options {
367 query_time_ms: self.query_time_ms,
368 query_memory_bytes: self.query_memory_bytes,
369 query_outputs_per_record: self.query_outputs_per_record,
370 query_scan_records: self.query_scan_records,
371 query_max_running: self.query_max_running,
372 query_worker: self.query_worker.clone(),
373 }
374 .resolve()
375 }
376
377 pub fn embed_settings(&self) -> Result<Option<embed::Settings>, String> {
383 embed::Options {
384 worker: self.embed_worker.clone(),
385 model_dir: self.embed_model_dir.clone(),
386 model_sha256: self.embed_model_sha256.clone(),
387 memory_bytes: self.embed_memory_bytes,
388 threads: self.embed_threads,
389 batch_items: self.embed_batch_items,
390 input_bytes: self.embed_input_bytes,
391 tokens: self.embed_tokens,
392 frame_bytes: self.embed_frame_bytes,
393 ready_ms: self.embed_ready_ms,
394 call_ms: self.embed_call_ms,
395 waiting: self.embed_waiting,
396 request_items: self.embed_request_items,
397 page_records: self.embed_page_records,
398 writer_wait_ms: self.embed_writer_wait_ms,
399 windows: self.embed_windows,
400 max_windows: self.embed_max_windows,
401 overlap_tokens: self.embed_window_overlap_tokens,
402 }
403 .resolve()
404 }
405}
406
407#[derive(Debug, Deserialize)]
408#[serde(deny_unknown_fields)]
409pub struct Creation {
410 pub shards: NonZeroUsize,
411 pub cache_bytes_total: NonZeroUsize,
412 pub executor_threads: NonZeroUsize,
413}
414
415const fn creation_config(data_dir: PathBuf, creation: &Creation) -> DatabaseConfig {
419 DatabaseConfig {
420 data_dir,
421 shard_count: creation.shards.get(),
422 distributed: None,
423 executor_threads: Some(creation.executor_threads.get()),
424 node_cache_budget: Some(NodeCacheBudget::Bytes(creation.cache_bytes_total)),
425 retention: haematite::Retention::reclaim(0),
426 }
427}
428
429fn claim_name(
430 config: &Config,
431 endpoint: &super::runtime::Endpoint,
432 create: bool,
433) -> Result<std::fs::File, String> {
434 let name_endpoint = if config.socket.is_some() {
435 #[cfg(test)]
436 let resolved = match endpoint_tests::input() {
437 Some((root, uid)) => super::runtime::resolve_input(
438 &config.data_dir,
439 Some(&endpoint.name),
440 None,
441 create,
442 &root,
443 uid,
444 ),
445 None => super::runtime::resolve(&config.data_dir, Some(&endpoint.name), None, create),
446 };
447 #[cfg(not(test))]
448 let resolved =
449 super::runtime::resolve(&config.data_dir, Some(&endpoint.name), None, create);
450 Some(resolved.map_err(|error| format!("bind service socket: {error}"))?)
451 } else {
452 None
453 };
454 let name_socket = name_endpoint
455 .as_ref()
456 .map_or(&endpoint.socket, |named| &named.socket);
457 let namespace = name_socket
458 .parent()
459 .ok_or("bind service socket: name endpoint needs a parent")?;
460 let name_uid = {
461 use std::os::unix::fs::MetadataExt;
462 std::fs::symlink_metadata(namespace)
463 .map_err(|error| format!("bind service socket: {error}"))?
464 .uid()
465 };
466 super::socket_owner::acquire_name(name_socket, name_uid)
467 .map_err(|error| format!("bind service socket: {error}"))
468}
469
470fn check_paths_and_timeout(config: &Config) -> Result<Duration, String> {
474 if !config.data_dir.is_absolute()
475 || config
476 .socket
477 .as_ref()
478 .is_some_and(|socket| !socket.is_absolute())
479 {
480 return Err("data_dir and socket must be absolute paths".to_owned());
481 }
482 let timeout = Duration::from_millis(config.timeout_ms.get());
483 if std::time::Instant::now().checked_add(timeout).is_none() {
484 return Err("timeout cannot be represented by the monotonic clock".to_owned());
485 }
486 if let Some(socket) = config.socket.as_ref() {
487 super::socket_owner::check_parent(socket)
488 .map_err(|error| format!("bind service socket: {error}"))?;
489 }
490 Ok(timeout)
491}
492
493pub fn run(args: impl Iterator<Item = String>) -> Result<(), String> {
497 let Some(options) = parse(args)? else {
498 println!("{USAGE}\n{}", long_wait_help());
499 return Ok(());
500 };
501 let config = read_config(&options.path)?;
502 let retention_limits = config.append_prune_limits()?;
504 let query_settings = config.query_settings()?;
505 let changes_settings = config.changes_settings()?;
506 let release = config.retention.settings()?;
507 let embedding = config.embed_settings()?;
509 let (gate, placer) = lys_parts(config.lys.as_ref())?;
510 let restart = embed::settings::Restart::of(
511 config.embed_restart_ms,
512 config.embed_restart_most_ms,
513 embedding
514 .as_ref()
515 .map_or(Duration::ZERO, |settings| settings.ready),
516 )?;
517 let timeout = check_paths_and_timeout(&config)?;
518 let endpoint = config
519 .endpoint(options.create)
520 .map_err(|error| error.to_string())?;
521 let name_ownership = claim_name(&config, &endpoint, options.create)?;
522 let query = query_settings.map(|settings| probe::prepare(settings, config.max_frame));
525 if let Some(Err(refusal)) = query.as_ref().map(probe::Prepared::ready) {
526 eprintln!("haem/1 query unavailable: {refusal}");
527 }
528 if let Err(error @ haematite::DatabaseError::LeafLogStore { .. }) =
531 haematite::Database::check_ordinary_store(&config.data_dir)
532 {
533 return Err(error.to_string());
534 }
535 let branch_cache = NodeCacheBudget::Bytes(config.branch_cache_bytes_per_shard);
536 let kept = config.kept_limit()?;
537 let storage = if options.create {
538 let creation = config
539 .create
540 .ok_or("--create needs a create section in the configuration")?;
541 Storage::create_kept(
542 creation_config(config.data_dir, &creation),
543 branch_cache,
544 kept,
545 options.long_wait_interval,
546 )
547 } else {
548 Storage::open_kept(
549 &config.data_dir,
550 branch_cache,
551 kept,
552 options.long_wait_interval,
553 )
554 }
555 .map_err(|error| error.to_string())?;
556 if options.create {
557 endpoint
558 .check_created()
559 .map_err(|error| error.to_string())?;
560 }
561 let mut service =
562 Service::with_query(storage, config.max_page, query).map_err(|error| error.to_string())?;
563 service.changes = changes_settings;
564 service.append_prune_limits = retention_limits;
565 service = served_through_lys(service, gate, placer)?;
566 service
567 .start_release(release)
568 .map_err(|error| error.to_string())?;
569 if let Some(settings) = embedding {
572 let bounds = super::embedding::bounds(&settings);
573 let writer_wait = settings.writer_wait;
574 let apart = !matches!(config.embed_question_worker, Setting::Present(false));
575 let questions = apart.then(|| super::embedding::embedder(settings.clone()));
576 service
577 .start_embedding_apart(
578 super::embedding::embedder(settings),
579 questions,
580 bounds,
581 writer_wait,
582 restart,
583 )
584 .map_err(|error| error.to_string())?;
585 }
586 let service = Arc::new(service);
587 service
588 .activate_reclaim()
589 .map_err(|error| format!("reclaim activation: {error}"))?;
590 let served = serve_listener(
591 &endpoint,
592 &service,
593 Limits {
594 max_frame: config.max_frame,
595 max_clients: config.max_clients,
596 frame_timeout: timeout,
597 },
598 );
599 drop(name_ownership);
600 served
601}
602
603fn serve_listener(
604 endpoint: &super::runtime::Endpoint,
605 service: &Arc<Service>,
606 limits: Limits,
607) -> Result<(), String> {
608 let mut listener = Listener::bind(&endpoint.socket)
609 .map_err(|error| format!("bind service socket: {error}"))?;
610 listener
611 .publish(endpoint)
612 .map_err(|error| error.to_string())?;
613 eprintln!("haem/1 ready on {}", endpoint.socket.display());
614 let served = listener
615 .run(service, limits)
616 .map_err(|error| format!("serve: {error}"));
617 let cleanup = listener.finish().map_err(|error| error.to_string());
618 drop(listener);
620 match (served, cleanup) {
621 (Ok(()), Ok(())) => Ok(()),
622 (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
623 (Err(served), Err(cleanup)) => Err(format!("{served}; {cleanup}")),
624 }
625}
626
627const RELEASE_USAGE: &str = "usage: haematite release date --config <file.json> --branch <name> \
628--generation <created> --at <nanoseconds> [--long-wait-interval <duration>]\n\
629Dates a generation discarded before its marker was written, as discarded at --at, so release \
630takes its rows by keep.days from then. --generation is the record's creation time its rows are \
631keyed by. Offline: the service must be stopped, as the store has one writer.";
632
633struct DateOptions {
634 branch: String,
635 generation: u64,
636 at: u64,
637 long_wait_interval: Duration,
638}
639
640#[path = "cli/branch_rename.rs"]
641mod branch_rename;
642pub use branch_rename::{BRANCH_RENAME_USAGE, branch_rename};
643
644pub fn release(mut args: impl Iterator<Item = String>) -> Result<(), String> {
655 if args.next().as_deref() != Some("date") {
656 return Err(RELEASE_USAGE.to_owned());
657 }
658 let mut args = args.peekable();
659 if args.peek().is_some_and(|arg| arg == "--help") {
660 if args.count() != 1 {
661 return Err(RELEASE_USAGE.to_owned());
662 }
663 println!("{RELEASE_USAGE}\n{}", long_wait_help());
664 return Ok(());
665 }
666 let (path, options) = parse_date(args)?;
667 let config = read_config(&path)?;
668 let branch_cache = NodeCacheBudget::Bytes(config.branch_cache_bytes_per_shard);
669 let mut storage = Storage::open_kept(
670 &config.data_dir,
671 branch_cache,
672 config.kept_limit()?,
673 options.long_wait_interval,
674 )
675 .map_err(|error| error.to_string())?;
676 let request = UndatedGeneration {
677 branch: options.branch,
678 generation: options.generation,
679 at: options.at,
680 };
681 let last = storage
682 .date_undated_generation(&request)
683 .map_err(|failure| failure.to_string())?;
684 println!("{}.{}", last.generation, last.sequence);
685 Ok(())
686}
687
688fn parse_date(mut args: impl Iterator<Item = String>) -> Result<(PathBuf, DateOptions), String> {
691 let (mut path, mut branch, mut generation, mut at) = (None, None, None, None);
692 let mut long_wait_interval = None;
693 while let Some(arg) = args.next() {
694 let value = args.next().ok_or(RELEASE_USAGE)?;
695 match arg.as_str() {
696 "--config" if path.is_none() => path = Some(PathBuf::from(value)),
697 "--branch" if branch.is_none() => branch = Some(value),
698 "--generation" if generation.is_none() => generation = Some(decimal(&arg, &value)?),
699 "--at" if at.is_none() => at = Some(decimal(&arg, &value)?),
700 "--long-wait-interval" if long_wait_interval.is_none() => {
701 long_wait_interval = Some(parse_long_wait_interval(&value)?);
702 }
703 _ => {
704 return Err(format!(
705 "unknown or repeated argument {arg:?}\n{RELEASE_USAGE}"
706 ));
707 }
708 }
709 }
710 let request = DateOptions {
711 long_wait_interval: long_wait_interval.unwrap_or(DEFAULT_LONG_WAIT_INTERVAL),
712 branch: branch.ok_or(RELEASE_USAGE)?,
713 generation: generation.ok_or(RELEASE_USAGE)?,
714 at: at.ok_or(RELEASE_USAGE)?,
715 };
716 Ok((path.ok_or(RELEASE_USAGE)?, request))
717}
718
719fn decimal(arg: &str, value: &str) -> Result<u64, String> {
720 value
721 .parse()
722 .map_err(|error| format!("{arg} {value:?}: {error}\n{RELEASE_USAGE}"))
723}
724
725fn read_config(path: &std::path::Path) -> Result<Config, String> {
726 let bytes = std::fs::read(path).map_err(|error| format!("read service config: {error}"))?;
727 serde_json::from_slice(&bytes).map_err(|error| format!("service config: {error}"))
728}
729
730fn parse_long_wait_interval(value: &str) -> Result<Duration, String> {
731 let invalid = || {
732 format!(
733 "invalid --long-wait-interval {value:?}: {}",
734 long_wait_help()
735 )
736 };
737 let end = value
738 .find(|character: char| !character.is_ascii_digit())
739 .ok_or_else(invalid)?;
740 let (number, unit) = value.split_at(end);
741 let amount: u64 = number.parse().map_err(|_| invalid())?;
742 let interval = match unit {
743 "ns" => Duration::from_nanos(amount),
744 "us" => Duration::from_micros(amount),
745 "ms" => Duration::from_millis(amount),
746 "s" => Duration::from_secs(amount),
747 "m" => Duration::from_secs(amount.checked_mul(60).ok_or_else(invalid)?),
748 "h" => Duration::from_secs(amount.checked_mul(3_600).ok_or_else(invalid)?),
749 _ => return Err(invalid()),
750 };
751 super::storage::runtime_options(interval).map_err(|error| error.to_string())?;
752 Ok(interval)
753}
754
755fn parse(args: impl Iterator<Item = String>) -> Result<Option<ServeOptions>, String> {
756 let mut args = args.peekable();
757 if args.peek().is_some_and(|arg| arg == "--help") {
758 return if args.count() == 1 {
759 Ok(None)
760 } else {
761 Err(USAGE.to_owned())
762 };
763 }
764 let mut path = None;
765 let mut create = false;
766 let mut long_wait_interval = None;
767 while let Some(arg) = args.next() {
768 match arg.as_str() {
769 "--config" if path.is_none() => path = Some(PathBuf::from(args.next().ok_or(USAGE)?)),
770 "--create" if !create => create = true,
771 "--long-wait-interval" if long_wait_interval.is_none() => {
772 long_wait_interval = Some(parse_long_wait_interval(&args.next().ok_or(USAGE)?)?);
773 }
774 _ => return Err(format!("unknown or repeated argument {arg:?}\n{USAGE}")),
775 }
776 }
777 Ok(Some(ServeOptions {
778 path: path.ok_or(USAGE)?,
779 create,
780 long_wait_interval: long_wait_interval.unwrap_or(DEFAULT_LONG_WAIT_INTERVAL),
781 }))
782}
783
784#[cfg(test)]
785#[path = "cli_tests.rs"]
786mod tests;
787
788#[cfg(test)]
789#[path = "cli_lys_tests.rs"]
790mod lys_tests;
791
792#[cfg(test)]
793mod long_wait_tests {
794 use std::error::Error;
795 use std::time::Duration;
796
797 use super::{DEFAULT_LONG_WAIT_INTERVAL, long_wait_help, parse, parse_date};
798
799 #[test]
800 fn serve_default_is_the_named_thirty_second_interval() -> Result<(), Box<dyn Error>> {
801 let options = parse(["--config", "/config.json"].map(str::to_owned).into_iter())?
802 .ok_or("serve options")?;
803 assert_eq!(DEFAULT_LONG_WAIT_INTERVAL, Duration::from_secs(30));
804 assert_eq!(options.long_wait_interval, DEFAULT_LONG_WAIT_INTERVAL);
805 assert!(long_wait_help().contains("default: 30s"));
806 Ok(())
807 }
808
809 #[test]
810 fn serve_explicit_interval_is_carried_into_the_engine_options() -> Result<(), Box<dyn Error>> {
811 let options = parse(
812 ["--config", "/config.json", "--long-wait-interval", "125ms"]
813 .map(str::to_owned)
814 .into_iter(),
815 )?
816 .ok_or("serve options")?;
817 let runtime = super::super::storage::runtime_options(options.long_wait_interval)?;
818 assert_eq!(runtime.long_wait_interval, Duration::from_millis(125));
819 Ok(())
820 }
821
822 #[test]
823 fn release_date_keeps_request_fields_and_carries_the_interval() -> Result<(), Box<dyn Error>> {
824 let arguments = [
825 "--config",
826 "/config.json",
827 "--branch",
828 "work",
829 "--generation",
830 "7",
831 "--at",
832 "9",
833 ];
834 let (path, options) = parse_date(arguments.map(str::to_owned).into_iter())?;
835 assert_eq!(path, std::path::PathBuf::from("/config.json"));
836 assert_eq!(
837 (options.branch.as_str(), options.generation, options.at),
838 ("work", 7, 9)
839 );
840 assert_eq!(options.long_wait_interval, DEFAULT_LONG_WAIT_INTERVAL);
841 let explicit = arguments
842 .into_iter()
843 .chain(["--long-wait-interval", "2m"])
844 .map(str::to_owned);
845 let (_, options) = parse_date(explicit)?;
846 let runtime = super::super::storage::runtime_options(options.long_wait_interval)?;
847 assert_eq!(runtime.long_wait_interval, Duration::from_secs(120));
848 Ok(())
849 }
850
851 #[test]
852 fn invalid_intervals_are_refused_at_the_cli_boundary() {
853 for value in [
854 "0s",
855 "-1s",
856 "1",
857 "1.5s",
858 "1fortnight",
859 "18446744073709551615h",
860 ] {
861 let parsed = parse(
862 ["--config", "/config.json", "--long-wait-interval", value]
863 .map(str::to_owned)
864 .into_iter(),
865 );
866 assert!(parsed.is_err(), "interval {value}");
867 }
868 let refused = parse(
869 ["--config", "/config.json", "--long-wait-interval", "0s"]
870 .map(str::to_owned)
871 .into_iter(),
872 );
873 assert!(
874 matches!(refused, Err(message) if message.contains("long-wait interval 0ns is refused"))
875 );
876 let repeated = parse(
877 [
878 "--config",
879 "/config.json",
880 "--long-wait-interval",
881 "1s",
882 "--long-wait-interval",
883 "2s",
884 ]
885 .map(str::to_owned)
886 .into_iter(),
887 );
888 assert!(repeated.is_err());
889 }
890
891 #[test]
892 fn help_does_not_require_a_configuration_or_open_a_store() -> Result<(), String> {
893 super::run(["--help".to_owned()].into_iter())?;
894 super::release(["date", "--help"].map(str::to_owned).into_iter())?;
895 Ok(())
896 }
897}
898
899#[cfg(test)]
900#[path = "cli_endpoint_tests.rs"]
901pub(crate) mod endpoint_tests;