Skip to main content

acorn/io/
source.rs

1//! Source readers for local paths, file URIs, and remote HTTP(S) URLs.
2use crate::io::{http, symlink, uri_to_path, ApiResult};
3use crate::prelude::{copy, read, Path, PathBuf};
4use crate::schema::agent::ModelDetails;
5use crate::util::Label;
6use crate::{Location, Repository, Scheme};
7use color_eyre::eyre::eyre;
8use core::fmt;
9use strum::EnumIs;
10use tracing::error;
11
12/// **Operational/transient** source — a one-shot parse-and-read type for I/O operations.
13///
14/// This is the complementary counterpart to [`Location`]: whereas `Location` is a descriptive
15/// data type meant for configuration and serialization, `Source` is a lightweight runtime type
16/// that drives actual byte reads from disk or HTTP(S).
17///
18/// Prefer to parse user-provided source strings with [`Source::read`] / [`Source::read_bytes`],
19/// and reserve [`Location`] for stored configuration values.
20#[derive(Clone, Debug, PartialEq, Eq, EnumIs)]
21pub enum Source {
22    /// Local filesystem path.
23    Local {
24        /// Optional display name for the source
25        name: Option<String>,
26        /// Local filesystem path
27        path: PathBuf,
28        /// Optional action to use when materializing the source
29        action: Option<SourceAction>,
30    },
31    /// Remote HTTP(S) URL
32    Remote {
33        /// Optional display name for the source
34        name: Option<String>,
35        /// Remote identifier, repository ID, or URL
36        identifier: String,
37    },
38    /// URI scheme that cannot be read as an ACORN source
39    Unsupported(String),
40}
41/// Action to use when materializing a local source into an output directory
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub enum SourceAction {
44    /// Reference the source in place
45    #[default]
46    Reference,
47    /// Copy the source into the output directory
48    Copy,
49    /// Create a symlink to the source in the output directory
50    Symlink,
51}
52impl SourceAction {
53    /// Resolve CLI `--copy` / `--symlink` flags into a source action.
54    pub fn from_options(copy: bool, symlink: bool) -> Option<Self> {
55        match (copy, symlink) {
56            | (true, false) => Some(Self::Copy),
57            | (false, true) => Some(Self::Symlink),
58            | _ => None,
59        }
60    }
61    /// Materialize ("reify" or "persist") source path locally according to a given action
62    pub fn materialize(self, path: &Path, target: &Path, name: &str) -> ApiResult<String> {
63        let result = match self {
64            | SourceAction::Copy if target.exists() => Ok(format!("Skipping copy - '{name}' already exists at {}", target.display())),
65            | SourceAction::Copy => match copy(path, target) {
66                | Ok(_) => Ok(format!("Copied local model '{name}' -> {}", target.display())),
67                | Err(why) => {
68                    error!("=> {} Copy local model '{}' to {} - {why}", Label::fail(), name, target.display());
69                    Err(why.into())
70                }
71            },
72            | SourceAction::Symlink if target.exists() || target.symlink_metadata().map(|metadata| metadata.is_symlink()).unwrap_or(false) => {
73                Ok(format!("Skipping symlink - '{name}' already exists at {}", target.display()))
74            }
75            | SourceAction::Symlink => match symlink(path, target) {
76                | Ok(_) => Ok(format!("Symlinked local model '{name}' -> {}", target.display())),
77                | Err(why) => {
78                    error!("=> {} Symlink local model '{}' to {} - {why}", Label::fail(), name, target.display());
79                    Err(why)
80                }
81            },
82            | SourceAction::Reference => Ok(format!("Local model '{name}' referenced in place at {}", path.display())),
83        };
84        result
85    }
86}
87impl Source {
88    /// Reads source content from a URL or local file path.
89    ///
90    /// When `source` starts with `http://` or `https://`, the content is downloaded.
91    /// Otherwise, the source is treated as a local file path and read from disk.
92    ///
93    /// Returns an error if URL access is requested while `offline` is enabled.
94    pub async fn read(source: &str, offline: bool) -> ApiResult<String> {
95        Self::read_bytes(source, offline)
96            .await
97            .and_then(|bytes| String::from_utf8(bytes).map_err(|why| eyre!("Failed to decode source as UTF-8 — {why}")))
98    }
99    /// Reads source bytes from a URL, file URI, or local file path.
100    ///
101    /// HTTP(S) sources are rejected when `offline` is enabled.
102    pub async fn read_bytes(source: &str, offline: bool) -> ApiResult<Vec<u8>> {
103        Source::read_parsed_bytes(Self::parse(source), offline).await
104    }
105    /// Parses a user-provided source string into a source location.
106    /// ### Note
107    /// Delegates URI scheme detection to [`Location::from_str`].
108    pub fn parse(source: &str) -> Self {
109        // Infallible — Location::from_str always succeeds (Err = Infallible)
110        let location: Location = source.parse().expect("Location::from_str is infallible");
111        match location {
112            | Location::Detailed { scheme: Scheme::File, .. } => {
113                let path = uri_to_path(source);
114                Self::Local {
115                    name: None,
116                    path,
117                    action: None,
118                }
119            }
120            | Location::Detailed {
121                scheme: Scheme::HTTPS | Scheme::HTTP,
122                ..
123            } => Self::Remote {
124                name: None,
125                identifier: source.to_string(),
126            },
127            | Location::Detailed {
128                scheme: Scheme::Unsupported, ..
129            } => Self::Unsupported(source.to_string()),
130            | Location::Simple(_) => Self::Local {
131                name: None,
132                path: PathBuf::from(source),
133                action: None,
134            },
135        }
136    }
137    /// Return the user-facing source name.
138    pub fn name(&self) -> String {
139        match self {
140            | Source::Local { name: Some(name), .. } | Source::Remote { name: Some(name), .. } => name.clone(),
141            | Source::Local { path, .. } => path.file_stem().and_then(|s| s.to_str()).unwrap_or("model").to_string(),
142            | Source::Remote { identifier, .. } | Source::Unsupported(identifier) => identifier.clone(),
143        }
144    }
145    /// Return a stable identifier for deduplication or output paths.
146    pub fn identifier(&self) -> String {
147        match self {
148            | Source::Local { path, .. } => path.display().to_string(),
149            | Source::Remote { identifier, .. } | Source::Unsupported(identifier) => identifier.clone(),
150        }
151    }
152    /// Return a source with a materialization action.
153    pub fn with_action(self, action: Option<SourceAction>) -> Self {
154        match self {
155            | Source::Local { name, path, .. } => Source::Local { name, path, action },
156            | other => other,
157        }
158    }
159    /// Return a source with a display name.
160    pub fn with_name(self, value: impl Into<String>) -> Self {
161        let binding = value.into();
162        let trimmed = binding.trim();
163        let name = (!trimmed.is_empty()).then(|| trimmed.to_string());
164        match self {
165            | Source::Local { path, action, .. } => Source::Local { name, path, action },
166            | Source::Remote { identifier, .. } => Source::Remote { name, identifier },
167            | Source::Unsupported(value) => Source::Unsupported(value),
168        }
169    }
170    async fn read_parsed_bytes(source: Source, offline: bool) -> ApiResult<Vec<u8>> {
171        match source {
172            | Source::Local { path, .. } => read(path).map_err(|why| eyre!("Failed to read source — {why}")),
173            | Source::Remote { identifier, .. } => Source::read_remote_bytes(&identifier, offline).await,
174            | Source::Unsupported(scheme) => Err(eyre!("Unsupported source URI scheme '{scheme}'")),
175        }
176    }
177    async fn read_remote_bytes(url: &str, offline: bool) -> ApiResult<Vec<u8>> {
178        match offline {
179            | true => Err(eyre!("Cannot read remote source while offline")),
180            | false => http::response_body_bytes(http::get(url).send().await, "Failed to download source").await,
181        }
182    }
183}
184impl fmt::Display for Source {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        let name = self.name();
187        let identifier = self.identifier();
188        if name == identifier {
189            write!(f, "{name}")
190        } else {
191            write!(f, "{name} ({identifier})")
192        }
193    }
194}
195impl From<&str> for Source {
196    fn from(selector: &str) -> Self {
197        let trimmed = selector.trim();
198        let location = Location::from(trimmed);
199        if location.is_local() {
200            let path = uri_to_path(trimmed);
201            Self::Local {
202                name: None,
203                path,
204                action: None,
205            }
206        } else {
207            Self::Remote {
208                name: Some(trimmed.to_string()),
209                identifier: trimmed.to_string(),
210            }
211        }
212    }
213}
214impl From<Location> for Source {
215    fn from(location: Location) -> Self {
216        let scheme = location.scheme();
217        let uri = location.uri().unwrap_or_default();
218        match scheme {
219            | Scheme::File => Self::Local {
220                name: None,
221                path: uri_to_path(&uri),
222                action: None,
223            },
224            | Scheme::HTTPS | Scheme::HTTP => Self::Remote { name: None, identifier: uri },
225            | Scheme::Unsupported => Self::Unsupported(uri),
226        }
227    }
228}
229impl From<&Repository> for Source {
230    fn from(repository: &Repository) -> Self {
231        match repository {
232            | Repository::HuggingFace { location } => Self::Remote {
233                name: None,
234                identifier: repository.id().unwrap_or_else(|| location.uri().unwrap_or_default()),
235            },
236            | _ => Self::from(repository.location()),
237        }
238    }
239}
240impl From<ModelDetails> for Option<Source> {
241    fn from(details: ModelDetails) -> Self {
242        details.weights.and_then(|weights| weights.to_source(details.name.or(details.id)))
243    }
244}