Skip to main content

greentic_setup/cli_helpers/
bundle.rs

1//! Bundle resolution and pack handling.
2//!
3//! Functions for resolving bundle sources (directories, .gtbundle files, URLs)
4//! and managing pack files.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use anyhow::{Context, Result, bail};
11use url::Url;
12
13use crate::cli_i18n::CliI18n;
14
15/// Resolve bundle source - supports both directories and .gtbundle files.
16pub fn resolve_bundle_source(path: &std::path::Path, i18n: &CliI18n) -> Result<PathBuf> {
17    use crate::gtbundle;
18
19    let path_str = path.to_string_lossy();
20    if path_str.starts_with("https://") || path_str.starts_with("http://") {
21        println!("{}", i18n.t("cli.simple.extracting"));
22        let temp_dir = download_and_extract_remote_bundle(&path_str)
23            .context("failed to fetch and extract remote bundle archive")?;
24        println!(
25            "{}",
26            i18n.tf(
27                "cli.simple.extracted_to",
28                &[&temp_dir.display().to_string()]
29            )
30        );
31        return Ok(temp_dir);
32    }
33
34    if gtbundle::is_gtbundle_file(path) {
35        println!("{}", i18n.t("cli.simple.extracting"));
36        let temp_dir = gtbundle::extract_gtbundle_to_temp(path)
37            .context("failed to extract .gtbundle archive")?;
38        println!(
39            "{}",
40            i18n.tf(
41                "cli.simple.extracted_to",
42                &[&temp_dir.display().to_string()]
43            )
44        );
45        return Ok(temp_dir);
46    }
47
48    if gtbundle::is_gtbundle_dir(path) {
49        return Ok(path.to_path_buf());
50    }
51    if path_str.ends_with(".gtbundle") && !path.exists() {
52        bail!(
53            "{}",
54            i18n.tf(
55                "setup.error.bundle_not_found",
56                &[&path.display().to_string()]
57            )
58        );
59    }
60
61    if path.is_dir() {
62        Ok(path.to_path_buf())
63    } else if path.exists() {
64        bail!(
65            "{}",
66            i18n.tf(
67                "cli.simple.expected_bundle_format",
68                &[&path.display().to_string()]
69            )
70        );
71    } else {
72        bail!(
73            "{}",
74            i18n.tf(
75                "setup.error.bundle_not_found",
76                &[&path.display().to_string()]
77            )
78        );
79    }
80}
81
82/// Persistent output target for simple setup flows.
83#[derive(Clone)]
84pub enum SetupOutputTarget {
85    Directory(PathBuf),
86    Archive(PathBuf),
87}
88
89/// Decide whether simple setup should materialize a configured local bundle.
90///
91/// - For remote `https://.../*.gtbundle`, write `./<file-name>.gtbundle` as a
92///   local bundle directory, matching the normal `gtc start ./demo.gtbundle`
93///   workspace flow.
94/// - For local archive paths, update that same archive in place.
95/// - For local bundle directories, keep working in the directory and do not emit
96///   a new artifact automatically.
97pub fn setup_output_target(source: &Path) -> Result<Option<SetupOutputTarget>> {
98    let source_str = source.to_string_lossy();
99
100    if source_str.starts_with("https://") || source_str.starts_with("http://") {
101        let parsed =
102            Url::parse(&source_str).with_context(|| format!("invalid bundle URL: {source_str}"))?;
103        let file_name = parsed
104            .path_segments()
105            .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
106            .filter(|segment| segment.ends_with(".gtbundle"))
107            .ok_or_else(|| {
108                anyhow::anyhow!("remote bundle URL must point to a .gtbundle archive")
109            })?;
110        return Ok(Some(SetupOutputTarget::Directory(
111            std::env::current_dir()?.join(file_name),
112        )));
113    }
114
115    if source_str.ends_with(".gtbundle") {
116        return Ok(Some(SetupOutputTarget::Archive(source.to_path_buf())));
117    }
118
119    Ok(None)
120}
121
122/// Download and extract a remote bundle archive.
123fn download_and_extract_remote_bundle(url: &str) -> Result<PathBuf> {
124    use crate::gtbundle;
125
126    let response = crate::http_client::download_agent()
127        .get(url)
128        .call()
129        .map_err(|err| anyhow::anyhow!("failed to fetch {url}: {err}"))?;
130    let bytes = response
131        .into_body()
132        .read_to_vec()
133        .map_err(|err| anyhow::anyhow!("failed to read {url}: {err}"))?;
134
135    let nonce = SystemTime::now()
136        .duration_since(UNIX_EPOCH)
137        .unwrap_or_default()
138        .as_nanos();
139    let base = std::env::temp_dir().join(format!("greentic-setup-remote-{nonce}"));
140    fs::create_dir_all(&base)?;
141
142    let file_name = url
143        .rsplit('/')
144        .next()
145        .filter(|value| !value.is_empty())
146        .unwrap_or("bundle.gtbundle");
147    let archive_path = base.join(file_name);
148    fs::write(&archive_path, bytes)?;
149
150    if !gtbundle::is_gtbundle_file(&archive_path) {
151        bail!("remote bundle URL must point to a .gtbundle archive: {url}");
152    }
153
154    gtbundle::extract_gtbundle_to_temp(&archive_path)
155}
156
157/// Resolve bundle directory from optional path argument.
158pub fn resolve_bundle_dir(bundle: Option<PathBuf>) -> Result<PathBuf> {
159    match bundle {
160        Some(path) => Ok(path),
161        None => std::env::current_dir().context("failed to get current directory"),
162    }
163}
164
165/// Recursively copy a directory tree.
166pub fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf, _only_used: bool) -> Result<()> {
167    if !src.is_dir() {
168        bail!("source is not a directory: {}", src.display());
169    }
170
171    std::fs::create_dir_all(dst)?;
172
173    for entry in std::fs::read_dir(src)? {
174        let entry = entry?;
175        let src_path = entry.path();
176        let dst_path = dst.join(entry.file_name());
177
178        if src_path.is_dir() {
179            copy_dir_recursive(&src_path, &dst_path, _only_used)?;
180        } else {
181            std::fs::copy(&src_path, &dst_path)?;
182        }
183    }
184
185    Ok(())
186}
187
188/// Detect provider domain from .gtpack filename prefix.
189///
190/// Known prefixes: messaging-, state-, telemetry-, events-, oauth-, secrets-.
191/// Falls back to "messaging" for unrecognized prefixes.
192pub fn detect_domain_from_filename(filename: &str) -> &'static str {
193    let stem = filename.strip_suffix(".gtpack").unwrap_or(filename);
194    if stem.starts_with("messaging-")
195        || stem.starts_with("state-")
196        || stem.starts_with("telemetry-")
197    {
198        "messaging"
199    } else if stem.starts_with("events-") || stem.starts_with("event-") {
200        "events"
201    } else if stem.starts_with("oauth-") {
202        "oauth"
203    } else if stem.starts_with("secrets-") {
204        "secrets"
205    } else {
206        "messaging"
207    }
208}
209
210/// Resolve a pack source (local path or OCI reference) to a local file path.
211pub fn resolve_pack_source(source: &str) -> Result<PathBuf> {
212    use crate::bundle_source::BundleSource;
213
214    let parsed = BundleSource::parse(source)?;
215
216    if parsed.is_local() {
217        let path = parsed.resolve()?;
218        if path.extension().and_then(|e| e.to_str()) != Some("gtpack") {
219            anyhow::bail!("Not a .gtpack file: {source}");
220        }
221        Ok(path)
222    } else {
223        println!("    Fetching from registry...");
224        let path = parsed.resolve()?;
225        println!("    Downloaded to cache: {}", path.display());
226        Ok(path)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::{SetupOutputTarget, setup_output_target};
233    use std::env;
234    use std::path::Path;
235    use tempfile::tempdir;
236
237    #[test]
238    fn setup_output_target_uses_cwd_file_name_directory_for_remote_bundle_urls() {
239        let cwd = env::current_dir().expect("cwd");
240        let dir = tempdir().expect("tempdir");
241        env::set_current_dir(dir.path()).expect("set cwd");
242
243        let output = setup_output_target(Path::new(
244            "https://github.com/greenticai/greentic-demo/releases/download/v0.1.9/cloud-deploy-demo.gtbundle",
245        ))
246        .expect("output target")
247        .expect("some output target");
248
249        match output {
250            SetupOutputTarget::Directory(path) => {
251                let expected = env::current_dir()
252                    .expect("cwd after set")
253                    .join("cloud-deploy-demo.gtbundle");
254                assert_eq!(path, expected);
255            }
256            SetupOutputTarget::Archive(path) => {
257                panic!("expected directory output, got archive {}", path.display());
258            }
259        }
260
261        env::set_current_dir(cwd).expect("restore cwd");
262    }
263
264    #[test]
265    fn setup_output_target_updates_local_archives_in_place() {
266        let archive = Path::new("/tmp/demo-bundle.gtbundle");
267        let output = setup_output_target(archive)
268            .expect("output target")
269            .expect("some output target");
270        match output {
271            SetupOutputTarget::Archive(path) => assert_eq!(path, archive),
272            SetupOutputTarget::Directory(path) => {
273                panic!("expected archive output, got directory {}", path.display());
274            }
275        }
276    }
277
278    #[test]
279    fn setup_output_target_skips_local_bundle_directories() {
280        let output = setup_output_target(Path::new("./demo-bundle")).expect("output target");
281        assert!(output.is_none());
282    }
283}