greentic_setup/cli_helpers/
bundle.rs1use 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
15pub 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
82pub enum SetupOutputTarget {
84 Directory(PathBuf),
85 Archive(PathBuf),
86}
87
88pub fn setup_output_target(source: &Path) -> Result<Option<SetupOutputTarget>> {
97 let source_str = source.to_string_lossy();
98
99 if source_str.starts_with("https://") || source_str.starts_with("http://") {
100 let parsed =
101 Url::parse(&source_str).with_context(|| format!("invalid bundle URL: {source_str}"))?;
102 let file_name = parsed
103 .path_segments()
104 .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
105 .filter(|segment| segment.ends_with(".gtbundle"))
106 .ok_or_else(|| {
107 anyhow::anyhow!("remote bundle URL must point to a .gtbundle archive")
108 })?;
109 return Ok(Some(SetupOutputTarget::Directory(
110 std::env::current_dir()?.join(file_name),
111 )));
112 }
113
114 if source_str.ends_with(".gtbundle") {
115 return Ok(Some(SetupOutputTarget::Archive(source.to_path_buf())));
116 }
117
118 Ok(None)
119}
120
121fn download_and_extract_remote_bundle(url: &str) -> Result<PathBuf> {
123 use crate::gtbundle;
124
125 let response = ureq::get(url)
126 .call()
127 .map_err(|err| anyhow::anyhow!("failed to fetch {url}: {err}"))?;
128 let bytes = response
129 .into_body()
130 .read_to_vec()
131 .map_err(|err| anyhow::anyhow!("failed to read {url}: {err}"))?;
132
133 let nonce = SystemTime::now()
134 .duration_since(UNIX_EPOCH)
135 .unwrap_or_default()
136 .as_nanos();
137 let base = std::env::temp_dir().join(format!("greentic-setup-remote-{nonce}"));
138 fs::create_dir_all(&base)?;
139
140 let file_name = url
141 .rsplit('/')
142 .next()
143 .filter(|value| !value.is_empty())
144 .unwrap_or("bundle.gtbundle");
145 let archive_path = base.join(file_name);
146 fs::write(&archive_path, bytes)?;
147
148 if !gtbundle::is_gtbundle_file(&archive_path) {
149 bail!("remote bundle URL must point to a .gtbundle archive: {url}");
150 }
151
152 gtbundle::extract_gtbundle_to_temp(&archive_path)
153}
154
155pub fn resolve_bundle_dir(bundle: Option<PathBuf>) -> Result<PathBuf> {
157 match bundle {
158 Some(path) => Ok(path),
159 None => std::env::current_dir().context("failed to get current directory"),
160 }
161}
162
163pub fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf, _only_used: bool) -> Result<()> {
165 if !src.is_dir() {
166 bail!("source is not a directory: {}", src.display());
167 }
168
169 std::fs::create_dir_all(dst)?;
170
171 for entry in std::fs::read_dir(src)? {
172 let entry = entry?;
173 let src_path = entry.path();
174 let dst_path = dst.join(entry.file_name());
175
176 if src_path.is_dir() {
177 copy_dir_recursive(&src_path, &dst_path, _only_used)?;
178 } else {
179 std::fs::copy(&src_path, &dst_path)?;
180 }
181 }
182
183 Ok(())
184}
185
186pub fn detect_domain_from_filename(filename: &str) -> &'static str {
191 let stem = filename.strip_suffix(".gtpack").unwrap_or(filename);
192 if stem.starts_with("messaging-")
193 || stem.starts_with("state-")
194 || stem.starts_with("telemetry-")
195 {
196 "messaging"
197 } else if stem.starts_with("events-") || stem.starts_with("event-") {
198 "events"
199 } else if stem.starts_with("oauth-") {
200 "oauth"
201 } else if stem.starts_with("secrets-") {
202 "secrets"
203 } else {
204 "messaging"
205 }
206}
207
208pub fn resolve_pack_source(source: &str) -> Result<PathBuf> {
210 use crate::bundle_source::BundleSource;
211
212 let parsed = BundleSource::parse(source)?;
213
214 if parsed.is_local() {
215 let path = parsed.resolve()?;
216 if path.extension().and_then(|e| e.to_str()) != Some("gtpack") {
217 anyhow::bail!("Not a .gtpack file: {source}");
218 }
219 Ok(path)
220 } else {
221 println!(" Fetching from registry...");
222 let path = parsed.resolve()?;
223 println!(" Downloaded to cache: {}", path.display());
224 Ok(path)
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::{SetupOutputTarget, setup_output_target};
231 use std::env;
232 use std::path::Path;
233 use tempfile::tempdir;
234
235 #[test]
236 fn setup_output_target_uses_cwd_file_name_directory_for_remote_bundle_urls() {
237 let cwd = env::current_dir().expect("cwd");
238 let dir = tempdir().expect("tempdir");
239 env::set_current_dir(dir.path()).expect("set cwd");
240
241 let output = setup_output_target(Path::new(
242 "https://github.com/greenticai/greentic-demo/releases/download/v0.1.9/cloud-deploy-demo.gtbundle",
243 ))
244 .expect("output target")
245 .expect("some output target");
246
247 match output {
248 SetupOutputTarget::Directory(path) => {
249 assert_eq!(path, dir.path().join("cloud-deploy-demo.gtbundle"));
250 }
251 SetupOutputTarget::Archive(path) => {
252 panic!("expected directory output, got archive {}", path.display());
253 }
254 }
255
256 env::set_current_dir(cwd).expect("restore cwd");
257 }
258
259 #[test]
260 fn setup_output_target_updates_local_archives_in_place() {
261 let archive = Path::new("/tmp/demo-bundle.gtbundle");
262 let output = setup_output_target(archive)
263 .expect("output target")
264 .expect("some output target");
265 match output {
266 SetupOutputTarget::Archive(path) => assert_eq!(path, archive),
267 SetupOutputTarget::Directory(path) => {
268 panic!("expected archive output, got directory {}", path.display());
269 }
270 }
271 }
272
273 #[test]
274 fn setup_output_target_skips_local_bundle_directories() {
275 let output = setup_output_target(Path::new("./demo-bundle")).expect("output target");
276 assert!(output.is_none());
277 }
278}