1use crate::io::api::github;
8use crate::io::api::gitlab;
9use crate::io::api::{Configuration, Endpoint};
10use crate::io::{files_all, read_file, with_progress, write_file, write_file_bytes, FromPath, InputOutput, ProgressType, Source};
11use crate::io::{ApiResult, Executor};
12use crate::prelude::{self, env, exit, Arc, PathBuf};
13use crate::util::constants::app::DEFAULT_CONFIG_FILENAMES;
14use crate::util::{detect_json, suffix, Label, MimeType, StringConversion};
15use crate::{Location, Repository, Scheme};
16use bon::Builder;
17use color_eyre::eyre::eyre;
18use core::fmt::{self, Debug};
19use core::future::Future;
20use derive_more::Display;
21use fancy_regex::Regex;
22use owo_colors::OwoColorize;
23use serde::{Deserialize, Serialize};
24use serde_with::skip_serializing_none;
25use tracing::error;
26
27const IGNORE: [&str; 5] = [".gitignore", ".gitlab-ci.yml", ".gitkeep", ".DS_Store", "README.md"];
28
29struct FilterSet {
30 ignore: Vec<Regex>,
31 filter: Vec<Regex>,
32}
33
34#[derive(Clone, Debug, Default, Display, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum RunnerStatus {
38 #[default]
40 Online,
41 Offline,
43 Stale,
45 NeverContacted,
47 Active,
49 Paused,
51}
52#[derive(Clone, Debug, Default, Serialize, Deserialize)]
56pub enum RunnerType {
57 #[default]
59 #[serde(rename = "group_type", alias = "group")]
60 Group,
61 #[serde(rename = "instance_type", alias = "instance")]
64 Instance,
65 #[serde(rename = "project_type", alias = "project")]
67 Project,
68}
69#[derive(Clone, Debug, Default, Serialize, eserde::Deserialize)]
94pub struct ApplicationConfiguration {
95 #[eserde(compat)]
97 pub buckets: Option<Vec<Bucket>>,
98 #[eserde(compat)]
100 pub endpoints: Option<Vec<Endpoint>>,
101 #[eserde(compat)]
103 pub runners: Option<Vec<RunnerDetails>>,
104}
105#[skip_serializing_none]
107#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109#[builder(start_fn = init)]
110pub struct Bucket {
111 pub name: Option<String>,
115 pub description: Option<String>,
119 #[serde(alias = "repository")]
123 pub code_repository: Repository,
124}
125#[derive(Builder, Clone, Debug)]
127#[builder(start_fn = init)]
128pub struct BucketOptions {
129 pub output: Option<PathBuf>,
131 #[builder(default = 10)]
133 pub threads: usize,
134 #[builder(default)]
136 pub quiet: bool,
137 #[builder(default)]
139 pub ignore: Vec<String>,
140 #[builder(default)]
142 pub filter: Vec<String>,
143}
144#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
145#[builder(start_fn = at, on(String, into))]
146#[serde(rename_all = "camelCase")]
147pub struct RunnerDetails {
149 #[builder(start_fn)]
151 #[serde(alias = "repository")]
152 pub code_repository: Repository,
153 pub name: Option<String>,
157 #[builder(default, with = |method: &str| RunnerType::from(method))]
159 #[serde(rename = "type")]
160 pub runner_type: RunnerType,
161 pub description: Option<String>,
163 #[builder(default = Executor::Docker)]
165 #[serde(default = "default_executor")]
166 pub executor: Executor,
167 #[builder(default)]
169 #[serde(default, alias = "gpu")]
170 pub gpu_enabled: bool,
171 #[serde(default, alias = "tag_list")]
173 pub tags: Option<Vec<String>>,
174 #[builder(default)]
176 #[serde(default, alias = "run_untagged")]
177 pub run_untagged: bool,
178 pub host: Option<String>,
180 #[builder(default = String::from("gitlab/gitlab-runner:latest"))]
182 #[serde(default = "default_docker_image")]
183 pub docker_image: String,
184 #[serde(default)]
186 pub identifier: Option<u64>,
187 #[serde(default)]
189 pub token: Option<String>,
190}
191impl ApplicationConfiguration {
192 pub fn resolve(path: &Option<PathBuf>) -> Option<PathBuf> {
197 path.as_ref().filter(|value| value.is_file()).cloned().or_else(|| {
198 let directory = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
199 DEFAULT_CONFIG_FILENAMES
200 .iter()
201 .map(|name| directory.join(name))
202 .find(|candidate| candidate.exists())
203 })
204 }
205 pub fn parse(content: impl AsRef<str>) -> ApiResult<Self> {
207 let content = content.as_ref();
208 if detect_json(content) {
209 match Self::parse_json(content) {
210 | Ok(value) => Ok(value),
211 | Err(errors) => {
212 let details: Vec<String> = errors
213 .iter()
214 .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
215 .collect();
216 Err(eyre!("{}", details.join("\n")))
217 }
218 }
219 } else {
220 match Self::parse_yaml(content) {
221 | Ok(value) => Ok(value),
222 | Err(why) => Err(eyre!("Failed to parse ACORN configuration YAML — {why}")),
223 }
224 }
225 }
226 fn parse_json(content: impl AsRef<str>) -> Result<Self, eserde::DeserializationErrors> {
227 eserde::json::from_str(content.as_ref())
228 }
229 fn parse_yaml(content: impl AsRef<str>) -> serde_norway::Result<Self> {
230 serde_norway::from_str(content.as_ref())
231 }
232}
233impl InputOutput for ApplicationConfiguration {
234 fn read(path: impl Into<PathBuf>) -> ApiResult<Self> {
236 let source = path.into();
237 match MimeType::from_path(&source) {
238 | MimeType::Json => Self::read_json(source.clone()),
239 | MimeType::Yaml => Self::read_yaml(source.clone()),
240 | _ => Err(eyre!("Unsupported configuration file extension")),
241 }
242 }
243 fn read_json(path: PathBuf) -> ApiResult<Self> {
245 let content = match read_file(path.clone()) {
246 | Ok(value) if !value.is_empty() => value,
247 | Ok(_) | Err(_) => {
248 error!(
249 path = path.to_string_lossy().to_string(),
250 "=> {} ACORN configuration JSON content",
251 Label::fail()
252 );
253 "{}".to_owned()
254 }
255 };
256 match Self::parse_json(content) {
257 | Ok(config) => Ok(config),
258 | Err(errors) => {
259 let details: Vec<String> = errors
260 .iter()
261 .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
262 .collect();
263 Err(eyre!("{}", details.join("\n")))
264 }
265 }
266 }
267 fn read_yaml(path: PathBuf) -> ApiResult<Self> {
269 let content = match read_file(path.clone()) {
270 | Ok(value) => value,
271 | Err(_) => {
272 error!(
273 path = path.to_string_lossy().to_string(),
274 "=> {} ACORN configuration YAML content",
275 Label::fail()
276 );
277 "".to_owned()
278 }
279 };
280 Self::parse_yaml(content).map_err(|why| eyre!("Failed to parse YAML config — {why}"))
281 }
282 fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
284 let target = path.into();
285 match MimeType::from_path(&target) {
286 | MimeType::Json => self.write_json(&target),
287 | MimeType::Yaml => self.write_yaml(&target),
288 | _ => Err(eyre!("Unsupported configuration file extension")),
289 }
290 }
291 fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
293 let target = path.into();
294 serde_json::to_string_pretty(&self)
295 .map_err(|why| eyre!("Failed to serialize JSON config — {why}"))
296 .and_then(|content| write_file(target.clone(), content))
297 }
298 fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
300 let target = path.into();
301 serde_norway::to_string(&self)
302 .map_err(|why| eyre!("Failed to serialize YAML config — {why}"))
303 .and_then(|content| write_file(target.clone(), content))
304 }
305}
306impl Bucket {
307 pub(crate) fn domain(&self) -> ApiResult<String> {
309 let location = match &self.code_repository {
310 | Repository::GitHub { location } | Repository::GitLab { location, .. } => location,
311 | Repository::Git { .. } => return Err(eyre!("Domain is unsupported for generic Git repositories")),
312 };
313 match location.scheme() {
314 | Scheme::HTTPS => location.host().ok_or_else(|| eyre!("Failed to parse repository host from URI")),
315 | _ => Err(eyre!("Unsupported repository URI scheme")),
316 }
317 }
318 pub async fn copy_files(self: Bucket, options: &BucketOptions) -> ApiResult<usize> {
323 let BucketOptions { output, ignore, filter, .. } = options;
324 let output = Arc::new(output.clone().unwrap_or_default());
325 match compile_filter_set(ignore, filter) {
326 | Ok(filters) => {
327 let Bucket { name, code_repository, .. } = self.clone();
328 match code_repository.is_local() {
329 | true => {
330 let bucket_root = match code_repository.location().path() {
331 | Some(value) => PathBuf::from(value).to_absolute_string(),
332 | None => {
333 return Err(eyre!(
334 "Bucket {} has no local path — cannot copy files",
335 name.as_deref().unwrap_or("unknown")
336 ))
337 }
338 };
339 let items = filter_paths(
340 files_all(PathBuf::from(&bucket_root), None)
341 .into_iter()
342 .map(|x| x.display().to_string())
343 .collect::<Vec<String>>(),
344 &filters,
345 )
346 .into_iter()
347 .filter(|path| PathBuf::from(path).is_file())
348 .collect::<Vec<String>>();
349 let bucket_root = Arc::new(bucket_root);
350 let operation = {
351 let bucket_root = Arc::clone(&bucket_root);
352 let output = Arc::clone(&output);
353 move |path: String| {
354 let bucket_root = Arc::clone(&bucket_root);
355 let output = Arc::clone(&output);
356 async move {
357 let path_buf = PathBuf::from(&path);
358 match path_buf.strip_prefix(PathBuf::from(bucket_root.as_str())) {
359 | Ok(relative) => write_file_bytes(output.join(relative), || async { prelude::read(path.clone()) }).await,
360 | Err(_) => Err(eyre!("Failed to strip prefix {bucket_root} from {path}")),
361 }
362 }
363 }
364 };
365 transfer_bucket_files(name, items, options, "Copying", operation).await
366 }
367 | false => Ok(0),
368 }
369 }
370 | Err(why) => Err(why),
371 }
372 }
373 pub async fn download_files(self: Bucket, options: &BucketOptions) -> ApiResult<usize> {
379 let BucketOptions { filter, ignore, .. } = options;
380 match compile_filter_set(ignore, filter) {
381 | Ok(filters) => {
382 let name = self.name.clone();
383 let code_repository = self.code_repository.clone();
384 match self.file_paths("").await {
385 | Ok(paths) => {
386 let items = filter_paths(paths, &filters);
387 let operation = {
388 let code_repository = Arc::new(code_repository);
389 let output = Arc::new(options.output.clone().unwrap_or_default());
390 move |path: String| {
391 let output = Arc::clone(&output);
392 let repository = Arc::clone(&code_repository);
393 async move {
394 match repository.raw_url(path.clone()) {
395 | Some(url) => {
396 let output_filepath = output.join(path);
397 write_file_bytes(output_filepath, || async move { Source::read_bytes(&url, false).await }).await
398 }
399 | None => Err(eyre!("Failed to build raw URL for repository path")),
400 }
401 }
402 }
403 };
404 transfer_bucket_files(name, items, options, "Downloading", operation).await
405 }
406 | Err(why) => {
407 error!("=> {} Get file paths for download — {why}", Label::fail());
408 Err(why)
409 }
410 }
411 }
412 | Err(why) => Err(why),
413 }
414 }
415 async fn file_paths(&self, directory: &str) -> ApiResult<Vec<String>> {
416 let code_repository = self.code_repository.clone();
417 let bucket_name = self.name.clone().unwrap_or_else(|| "Bucket".to_string()).to_uppercase();
418 match &code_repository {
419 | Repository::Git { .. } => {
420 let path = match code_repository.location().path() {
421 | Some(value) => PathBuf::from(value),
422 | None => return Err(eyre!("Git repository has no local path — cannot list files")),
423 };
424 Ok(files_all(path, None).into_iter().map(|x| x.display().to_string()).collect())
425 }
426 | Repository::GitHub { location } => match location.path() {
427 | Some(path) => {
428 let path = path.trim_start_matches('/').to_string();
429 match self.domain() {
430 | Ok(host) => github::tree_paths(format!("api.{}", host), path, "main")
431 .await
432 .map_err(|why| eyre!("Failed to get file paths for {bucket_name} bucket - {why}")),
433 | Err(why) => Err(why),
434 }
435 }
436 | None => Err(eyre!("Failed to parse GitHub URI for {bucket_name} bucket")),
437 },
438 | Repository::GitLab { .. } => match code_repository.id() {
439 | Some(id) => match self.domain() {
440 | Ok(host) => {
441 let options = gitlab::Options::from_env().with_domain(host).with_identifier(id).with_path(directory);
442 let mut page = 1_u32;
443 let mut all_paths: Vec<String> = vec![];
444 loop {
445 let page_options = options.clone().with_page(page);
446 match gitlab::tree_paths(&page_options).await {
447 | Ok(response) if response.paths.is_empty() => {
448 break Ok(all_paths.clone());
449 }
450 | Ok(response) => {
451 all_paths.extend(response.paths);
452 page = page.saturating_add(1);
453 }
454 | Err(why) => {
455 break Err(eyre!("Failed to get file paths for {bucket_name} bucket — {why}"));
456 }
457 }
458 }
459 }
460 | Err(why) => Err(why),
461 },
462 | None => Err(eyre!("Missing GitLab project id for {bucket_name} bucket")),
463 },
464 }
465 }
466}
467impl From<&str> for Bucket {
468 fn from(value: &str) -> Self {
469 let location = Location::Simple(value.to_string());
470 if location.uri().is_none() {
471 exit(exitcode::DATAERR);
472 }
473 let repository = match location.scheme() {
474 | Scheme::File => Repository::Git { location },
475 | _ => {
476 let host = match location.host() {
477 | Some(value) => value.to_lowercase(),
478 | None => {
479 error!(value, "=> {} Parse URI - No host", Label::fail());
480 exit(exitcode::DATAERR);
481 }
482 };
483 if host.contains("github.com") {
484 Repository::GitHub { location }
485 } else {
486 let id = None;
487 Repository::GitLab { id, location }
488 }
489 }
490 };
491 Bucket::init().code_repository(repository).build()
492 }
493}
494impl Default for BucketOptions {
495 fn default() -> Self {
496 Self {
497 output: None,
498 threads: 10,
499 quiet: false,
500 ignore: Vec::new(),
501 filter: Vec::new(),
502 }
503 }
504}
505impl BucketOptions {
506 pub fn with_output(self, output: impl Into<PathBuf>) -> Self {
508 Self {
509 output: Some(output.into()),
510 ..self
511 }
512 }
513}
514impl fmt::Display for RunnerType {
515 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
516 let value = match self {
517 | RunnerType::Group => "group",
518 | RunnerType::Instance => "instance",
519 | RunnerType::Project => "project",
520 };
521 formatter.write_str(value)
522 }
523}
524impl RunnerDetails {
525 pub fn with_id(self, value: u64) -> Self {
527 Self {
528 identifier: Some(value),
529 ..self
530 }
531 }
532 pub fn with_name(self, value: String) -> Self {
534 Self { name: Some(value), ..self }
535 }
536 pub fn with_token(self, value: Option<String>) -> Self {
538 Self { token: value, ..self }
539 }
540}
541impl From<&str> for RunnerType {
542 fn from(value: &str) -> Self {
543 match value.to_uppercase().as_str() {
544 | "GROUP" => RunnerType::Group,
545 | "INSTANCE" => RunnerType::Instance,
546 | "PROJECT" => RunnerType::Project,
547 | _ => RunnerType::Group,
548 }
549 }
550}
551impl From<String> for RunnerType {
552 fn from(value: String) -> Self {
553 Self::from(value.as_str())
554 }
555}
556fn compile_filter_set(ignore: &[String], filter: &[String]) -> ApiResult<FilterSet> {
557 compile_regex_patterns(ignore).and_then(|ignore| compile_regex_patterns(filter).map(|filter| FilterSet { ignore, filter }))
558}
559fn compile_regex_patterns(patterns: &[String]) -> ApiResult<Vec<Regex>> {
560 patterns
561 .iter()
562 .map(|pattern| Regex::new(pattern).map_err(|why| eyre!("Invalid regex/filter pattern '{pattern}': {why}")))
563 .collect()
564}
565fn count_json_files(paths: &[String]) -> usize {
566 paths.iter().filter(|&path| path.to_lowercase().ends_with(".json")).count()
567}
568fn count_image_files(paths: &[String]) -> usize {
569 paths.iter().filter(|&x| has_image_extension(x)).count()
570}
571fn default_docker_image() -> String {
572 "gitlab/gitlab-runner:latest".to_string()
573}
574fn default_executor() -> Executor {
575 Executor::Docker
576}
577fn filter_paths(paths: Vec<String>, filters: &FilterSet) -> Vec<String> {
578 paths
579 .into_iter()
580 .filter(|path| !is_ignored_path(path, &filters.ignore) && is_filtered_path(path, &filters.filter))
581 .collect()
582}
583#[allow(clippy::ptr_arg)]
584fn has_image_extension(path: &String) -> bool {
585 path.to_lowercase().ends_with(".png") || path.to_lowercase().ends_with(".jpg")
586}
587fn is_ignored_path(path: &str, ignore: &[Regex]) -> bool {
588 let is_builtin_ignored = IGNORE.iter().any(|value| path.ends_with(value));
589 let is_regex_ignored = ignore.iter().any(|pattern| pattern.is_match(path).unwrap_or(false));
590 is_builtin_ignored || is_regex_ignored
591}
592fn is_filtered_path(path: &str, filter: &[Regex]) -> bool {
593 filter.is_empty() || filter.iter().any(|pattern| pattern.is_match(path).unwrap_or(false))
594}
595fn operations_complete_message(name: Option<String>, json_count: usize, image_count: usize) -> String {
596 let total = json_count.saturating_add(image_count);
597 let message = if json_count != image_count {
598 let recommendation = if json_count > image_count {
599 "Do you need to add some images?"
600 } else {
601 "Do you need to add some JSON files?"
602 };
603 format!(
604 " ({} data file{}, {} image{} - {})",
605 json_count.yellow(),
606 suffix(json_count),
607 image_count.yellow(),
608 suffix(image_count),
609 recommendation.italic(),
610 )
611 } else {
612 "".to_string()
613 };
614 let bucket_description = match name {
615 | Some(value) => format!("{} bucket", value.to_uppercase().cyan()),
616 | None => "<URL>".cyan().to_string(),
617 };
618 format!(
619 "{}Obtained {} file{} from {bucket_description}{}",
620 if total > 0 { Label::CHECKMARK } else { Label::CAUTION },
621 if total > 0 {
622 total.green().to_string()
623 } else {
624 total.yellow().to_string()
625 },
626 suffix(total),
627 message,
628 )
629}
630async fn transfer_bucket_files<F, Fut>(
631 name: Option<String>,
632 items: Vec<String>,
633 options: &BucketOptions,
634 verb: &'static str,
635 operation: F,
636) -> ApiResult<usize>
637where
638 F: Fn(String) -> Fut,
639 Fut: Future<Output = ApiResult<()>>,
640{
641 let BucketOptions { threads, quiet, .. } = options;
642 let total_data = count_json_files(&items);
643 let total_images = count_image_files(&items);
644 let total = total_data.saturating_add(total_images);
645 let message = move |path: &String| format!("{verb} {path}");
646 let finish_message = |_| operations_complete_message(name, total_data, total_images);
647 let progress_type = match quiet {
648 | true => ProgressType::Silent,
649 | false => ProgressType::Bar,
650 };
651 with_progress(items, message, operation, finish_message, Some(*threads), progress_type)
652 .await
653 .map(|_| total)
654}
655
656#[cfg(test)]
657mod tests {
658 #![allow(
659 clippy::unwrap_used,
660 clippy::expect_used,
661 clippy::panic,
662 clippy::indexing_slicing,
663 clippy::arithmetic_side_effects
664 )]
665 use super::*;
666 use crate::prelude::{create_dir_all, remove_dir_all, write};
667
668 fn temp_resolve_dir(name: &str) -> PathBuf {
669 let nanos = std::time::SystemTime::now()
670 .duration_since(std::time::UNIX_EPOCH)
671 .unwrap_or(core::time::Duration::from_nanos(0))
672 .as_nanos();
673 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
674 .join("..")
675 .join("target")
676 .join("test_artifacts")
677 .join(format!("{name}-{nanos}"))
678 }
679
680 #[test]
681 fn test_resolve_returns_explicit_existing_path() {
682 let directory = temp_resolve_dir("resolve-explicit");
683 create_dir_all(&directory).unwrap();
684 let directory = directory.canonicalize().unwrap();
685 let provided = directory.join("config.yaml");
686 let default = directory.join(".acorn.json");
687 write(&provided, "{}\n").unwrap();
688 write(&default, "{}\n").unwrap();
689 let resolved = ApplicationConfiguration::resolve(&Some(provided.clone()));
690 assert_eq!(resolved, Some(provided));
691 let _ = remove_dir_all(directory);
692 }
693 #[test]
694 fn test_resolve_falls_back_to_default_when_provided_path_missing() {
695 let directory = temp_resolve_dir("resolve-fallback");
696 create_dir_all(&directory).unwrap();
697 let directory = directory.canonicalize().unwrap();
698 let default = directory.join(".acorn.yml");
699 write(&default, "{}\n").unwrap();
700 let previous = env::current_dir().unwrap();
701 env::set_current_dir(&directory).unwrap();
702 let resolved = ApplicationConfiguration::resolve(&Some(directory.join("missing.json")));
703 env::set_current_dir(previous).unwrap();
704 assert_eq!(resolved, Some(default));
705 let _ = remove_dir_all(directory);
706 }
707 #[test]
708 fn test_count_image_files_counts_supported_extensions() {
709 let paths = vec![
710 "content/plot.png".to_string(),
711 "content/photo.jpg".to_string(),
712 "content/photo.jpeg".to_string(),
713 "content/index.json".to_string(),
714 ];
715 assert_eq!(count_image_files(&paths), 2);
716 }
717 #[test]
718 fn test_count_json_files_counts_case_insensitive_json_paths() {
719 let paths = vec![
720 "content/index.json".to_string(),
721 "content/README.md".to_string(),
722 "content/data.JSON".to_string(),
723 ];
724 assert_eq!(count_json_files(&paths), 2);
725 }
726 #[test]
727 fn test_has_image_extension_matches_png_and_jpg() {
728 assert!(has_image_extension(&"image.png".to_string()));
729 assert!(has_image_extension(&"photo.JPG".to_string()));
730 assert!(!has_image_extension(&"graphic.jpeg".to_string()));
731 }
732 #[test]
733 fn test_is_ignored_path() {
734 let ignore = compile_regex_patterns(&[r"\.jpeg$".to_string(), r"notes\.txt$".to_string()]).unwrap();
735 assert!(is_ignored_path("/tmp/photo.jpeg", &ignore));
736 assert!(is_ignored_path("/tmp/notes.txt", &ignore));
737 assert!(!is_ignored_path("/tmp/index.json", &ignore));
738 let invalid = compile_regex_patterns(&["[".to_string()]);
739 assert!(invalid.is_err());
740 let ignore: Vec<Regex> = vec![];
741 assert!(is_ignored_path("/tmp/README.md", &ignore));
742 }
743 #[test]
744 fn test_is_filtered_path() {
745 let filter = compile_regex_patterns(&[r"\.json$".to_string(), r"img/".to_string()]).unwrap();
746 assert!(is_filtered_path("/tmp/data.json", &filter));
747 assert!(is_filtered_path("/tmp/img/photo.jpg", &filter));
748 assert!(!is_filtered_path("/tmp/README.md", &filter));
749 let invalid = compile_regex_patterns(&["[".to_string()]);
750 assert!(invalid.is_err());
751 let filter: Vec<Regex> = vec![];
752 assert!(is_filtered_path("/tmp/README.md", &filter));
753 }
754 #[test]
755 fn test_operations_complete_message_includes_bucket_name_and_guidance() {
756 let message = operations_complete_message(Some("acorn".to_string()), 2, 1);
757 assert!(message.contains("Obtained"));
758 assert!(message.contains("ACORN"));
759 assert!(message.contains(" bucket"));
760 assert!(message.contains("data file"));
761 assert!(message.contains("image"));
762 assert!(message.contains("Do you need to add some images?"));
763 }
764 #[test]
765 fn test_operations_complete_message_uses_url_placeholder_without_name() {
766 let message = operations_complete_message(None, 0, 0);
767 assert!(message.contains("Obtained"));
768 assert!(message.contains("<URL>"));
769 }
770}