1use std::fs::{self, OpenOptions};
2use std::io::Write;
3
4use camino::{Utf8Path, Utf8PathBuf};
5use rustferry_core::{
6 FerryConfig, NetworkMode, ProjectNames, TargetPlatform, derive_project_names,
7};
8use thiserror::Error;
9
10use crate::templates::{TemplateContext, project_files};
11
12#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
14pub enum TemplateKind {
15 #[default]
17 Starter,
18 Minimal,
20 Counter,
22 Network,
24 Notifications,
26 Widget,
28 LiveActivity,
30 KitchenSink,
32}
33
34impl TemplateKind {
35 pub const fn as_str(self) -> &'static str {
37 match self {
38 Self::Starter => "starter",
39 Self::Minimal => "minimal",
40 Self::Counter => "counter",
41 Self::Network => "network",
42 Self::Notifications => "notifications",
43 Self::Widget => "widget",
44 Self::LiveActivity => "live-activity",
45 Self::KitchenSink => "kitchen-sink",
46 }
47 }
48}
49
50#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
52pub enum PlatformSelection {
53 Android,
55 Ios,
57 #[default]
59 Both,
60}
61
62impl PlatformSelection {
63 fn platforms(self) -> Vec<TargetPlatform> {
64 match self {
65 Self::Android => vec![TargetPlatform::Android],
66 Self::Ios => vec![TargetPlatform::Ios],
67 Self::Both => vec![TargetPlatform::Android, TargetPlatform::Ios],
68 }
69 }
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
74pub enum RuntimeDependency {
75 Registry(String),
77 Workspace,
79 Path(Utf8PathBuf),
81}
82
83#[derive(Clone, Debug)]
85pub struct ProjectRequest {
86 pub name: String,
88 pub display_name: Option<String>,
90 pub identifier: Option<String>,
92 pub template: TemplateKind,
94 pub platforms: PlatformSelection,
96 pub runtime_dependency: RuntimeDependency,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct GenerationPlan {
103 pub destination: Utf8PathBuf,
105 pub names: ProjectNames,
107 pub files: Vec<Utf8PathBuf>,
109}
110
111#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct GeneratedProject {
114 pub destination: Utf8PathBuf,
116 pub names: ProjectNames,
118 pub files: Vec<Utf8PathBuf>,
120}
121
122#[derive(Clone, Debug)]
124pub struct ProjectGenerator {
125 parent: Utf8PathBuf,
126 request: ProjectRequest,
127}
128
129impl ProjectGenerator {
130 pub fn new(parent: impl Into<Utf8PathBuf>, request: ProjectRequest) -> Self {
132 Self {
133 parent: parent.into(),
134 request,
135 }
136 }
137
138 pub fn plan(&self) -> Result<GenerationPlan, GenerationError> {
145 let (names, context) = self.context()?;
146 let destination = self.parent.join(&names.directory_name);
147 let files = project_files(&context)?
148 .into_iter()
149 .map(|file| file.relative_path)
150 .collect();
151 Ok(GenerationPlan {
152 destination,
153 names,
154 files,
155 })
156 }
157
158 pub fn generate(&self) -> Result<GeneratedProject, GenerationError> {
165 let parent =
166 self.parent
167 .canonicalize_utf8()
168 .map_err(|source| GenerationError::ParentDirectory {
169 path: self.parent.clone(),
170 source,
171 })?;
172 let (names, context) = self.context()?;
173 let destination = parent.join(&names.directory_name);
174 if destination
175 .try_exists()
176 .map_err(|source| GenerationError::Inspect {
177 path: destination.clone(),
178 source,
179 })?
180 {
181 return Err(GenerationError::DestinationExists(destination));
182 }
183
184 let files = project_files(&context)?;
185 let temporary = tempfile::Builder::new()
186 .prefix(".cargo-ferry-new-")
187 .tempdir_in(&parent)
188 .map_err(|source| GenerationError::TemporaryDirectory {
189 path: parent.clone(),
190 source,
191 })?;
192 let temporary_path = Utf8PathBuf::from_path_buf(temporary.path().to_path_buf())
193 .map_err(GenerationError::NonUtf8Path)?;
194
195 for file in &files {
196 write_new_file(&temporary_path, &file.relative_path, &file.contents)?;
197 }
198
199 let staging =
200 Utf8PathBuf::from_path_buf(temporary.keep()).map_err(GenerationError::NonUtf8Path)?;
201 fs::rename(&staging, &destination).map_err(|source| GenerationError::Commit {
202 staging,
203 destination: destination.clone(),
204 source,
205 })?;
206
207 Ok(GeneratedProject {
208 destination,
209 names,
210 files: files.into_iter().map(|file| file.relative_path).collect(),
211 })
212 }
213
214 fn context(&self) -> Result<(ProjectNames, TemplateContext), GenerationError> {
215 let mut names =
216 derive_project_names(&self.request.name, self.request.identifier.as_deref())?;
217 if let Some(display_name) = &self.request.display_name {
218 validate_display_name(display_name)?;
219 names.display_name.clone_from(display_name);
220 }
221 let mut config = config_for_template(&names, self.request.template);
222 config.platforms = self.request.platforms.platforms();
223 config.validate_or_error()?;
224 let context = TemplateContext {
225 names: names.clone(),
226 config,
227 kind: self.request.template,
228 runtime_dependency: self.request.runtime_dependency.clone(),
229 };
230 Ok((names, context))
231 }
232}
233
234fn validate_display_name(display_name: &str) -> Result<(), GenerationError> {
235 let length = display_name.chars().count();
236 if display_name.trim() != display_name
237 || !(1..=128).contains(&length)
238 || display_name.chars().any(char::is_control)
239 {
240 return Err(GenerationError::InvalidDisplayName {
241 message:
242 "use 1–128 characters without leading/trailing whitespace or control characters"
243 .to_owned(),
244 });
245 }
246 Ok(())
247}
248
249fn config_for_template(names: &ProjectNames, kind: TemplateKind) -> FerryConfig {
250 let mut config = FerryConfig::starter(&names.display_name, &names.application_identifier);
251 match kind {
252 TemplateKind::Starter => {}
253 TemplateKind::KitchenSink => {
254 config.capabilities.clipboard.enabled = true;
255 config.capabilities.share.enabled = true;
256 config.extensions.widget.enabled = true;
257 config.extensions.widget.app_group =
258 Some(format!("group.{}", names.application_identifier));
259 config.extensions.live_activity.enabled = true;
260 "16.1".clone_into(&mut config.ios.min_version);
261 }
262 TemplateKind::Minimal => {
263 config.capabilities = rustferry_core::CapabilitiesConfig::default();
264 }
265 TemplateKind::Counter => {
266 config.capabilities.network.mode = NetworkMode::None;
267 config.capabilities.notifications.local = false;
268 }
269 TemplateKind::Network => {
270 config.capabilities.network.mode = NetworkMode::Optional;
271 config.capabilities.notifications.local = false;
272 }
273 TemplateKind::Notifications => {
274 config.capabilities = rustferry_core::CapabilitiesConfig::default();
275 config.capabilities.notifications.local = true;
276 config.capabilities.storage.enabled = true;
277 }
278 TemplateKind::Widget => {
279 config.extensions.widget.enabled = true;
280 config.extensions.widget.app_group =
281 Some(format!("group.{}", names.application_identifier));
282 }
283 TemplateKind::LiveActivity => {
284 "16.1".clone_into(&mut config.ios.min_version);
285 config.extensions.live_activity.enabled = true;
286 }
287 }
288 config
289}
290
291fn write_new_file(
292 root: &Utf8Path,
293 relative_path: &Utf8Path,
294 contents: &[u8],
295) -> Result<(), GenerationError> {
296 if relative_path.is_absolute()
297 || relative_path
298 .components()
299 .any(|component| matches!(component, camino::Utf8Component::ParentDir))
300 {
301 return Err(GenerationError::UnsafeTemplatePath(
302 relative_path.to_owned(),
303 ));
304 }
305 let path = root.join(relative_path);
306 if let Some(parent) = path.parent() {
307 fs::create_dir_all(parent).map_err(|source| GenerationError::Write {
308 path: parent.to_owned(),
309 source,
310 })?;
311 }
312 let mut file = OpenOptions::new()
313 .write(true)
314 .create_new(true)
315 .open(&path)
316 .map_err(|source| GenerationError::Write {
317 path: path.clone(),
318 source,
319 })?;
320 file.write_all(contents)
321 .and_then(|()| file.sync_all())
322 .map_err(|source| GenerationError::Write { path, source })
323}
324
325#[derive(Debug, Error)]
327pub enum GenerationError {
328 #[error(transparent)]
330 Naming(#[from] rustferry_core::NamingError),
331 #[error("invalid display name: {message}")]
333 InvalidDisplayName {
334 message: String,
336 },
337 #[error(transparent)]
339 Config(#[from] rustferry_core::ConfigError),
340 #[error("could not access project parent {path}: {source}")]
342 ParentDirectory {
343 path: Utf8PathBuf,
345 #[source]
347 source: std::io::Error,
348 },
349 #[error("could not inspect destination {path}: {source}")]
351 Inspect {
352 path: Utf8PathBuf,
354 #[source]
356 source: std::io::Error,
357 },
358 #[error("destination already exists: {0}")]
360 DestinationExists(Utf8PathBuf),
361 #[error("could not create a temporary project below {path}: {source}")]
363 TemporaryDirectory {
364 path: Utf8PathBuf,
366 #[source]
368 source: std::io::Error,
369 },
370 #[error("path is not valid UTF-8: {0:?}")]
372 NonUtf8Path(std::path::PathBuf),
373 #[error("built-in template path is unsafe: {0}")]
375 UnsafeTemplatePath(Utf8PathBuf),
376 #[error("could not write generated path {path}: {source}")]
378 Write {
379 path: Utf8PathBuf,
381 #[source]
383 source: std::io::Error,
384 },
385 #[error("generated files remain at {staging}; could not move them to {destination}: {source}")]
387 Commit {
388 staging: Utf8PathBuf,
390 destination: Utf8PathBuf,
392 #[source]
394 source: std::io::Error,
395 },
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn request(name: &str) -> ProjectRequest {
403 ProjectRequest {
404 name: name.to_owned(),
405 display_name: None,
406 identifier: None,
407 template: TemplateKind::Starter,
408 platforms: PlatformSelection::Both,
409 runtime_dependency: RuntimeDependency::Registry("0.1.0".to_owned()),
410 }
411 }
412
413 #[test]
414 fn generation_is_atomic_and_refuses_overwrite() {
415 let parent = tempfile::tempdir().unwrap();
416 let parent = Utf8Path::from_path(parent.path()).unwrap();
417 let generator = ProjectGenerator::new(parent, request("weather"));
418 let generated = generator.generate().unwrap();
419 assert!(generated.destination.join("src/app.rs").is_file());
420 assert!(matches!(
421 generator.generate(),
422 Err(GenerationError::DestinationExists(_))
423 ));
424 }
425
426 #[test]
427 fn dry_run_does_not_create_destination() {
428 let parent = tempfile::tempdir().unwrap();
429 let parent = Utf8Path::from_path(parent.path()).unwrap();
430 let generator = ProjectGenerator::new(parent, request("weather"));
431 let plan = generator.plan().unwrap();
432 assert!(!plan.destination.exists());
433 assert!(plan.files.contains(&Utf8PathBuf::from("Cargo.toml")));
434 }
435
436 #[test]
437 fn unicode_parent_and_name_work() {
438 let parent = tempfile::tempdir().unwrap();
439 let unicode_parent = parent.path().join("путь с пробелом");
440 fs::create_dir(&unicode_parent).unwrap();
441 let parent = Utf8PathBuf::from_path_buf(unicode_parent).unwrap();
442 let generated = ProjectGenerator::new(parent, request("Погода"))
443 .generate()
444 .unwrap();
445 assert!(generated.destination.join("ferry.toml").is_file());
446 }
447
448 #[test]
449 fn starter_contains_real_ui_and_no_native_project() {
450 let parent = tempfile::tempdir().unwrap();
451 let parent = Utf8Path::from_path(parent.path()).unwrap();
452 let generated = ProjectGenerator::new(parent, request("weather"))
453 .generate()
454 .unwrap();
455 let app = fs::read_to_string(generated.destination.join("src/app.rs")).unwrap();
456 assert!(app.contains("callback increment"));
457 assert!(app.contains("use_app_events"));
458 assert!(app.contains("request_permission().await"));
459 assert!(app.contains("AboutSlint"));
460 assert!(!app.contains("{{"));
461 assert!(!generated.destination.join("android").exists());
462 assert!(!generated.destination.join("ios").exists());
463 assert!(!generated.destination.join("build.gradle").exists());
464 }
465
466 #[test]
467 fn minimal_readme_describes_only_generated_files() {
468 let parent = tempfile::tempdir().unwrap();
469 let parent = Utf8Path::from_path(parent.path()).unwrap();
470 let mut minimal = request("minimal");
471 minimal.template = TemplateKind::Minimal;
472 let generated = ProjectGenerator::new(parent, minimal).generate().unwrap();
473 let readme = fs::read_to_string(generated.destination.join("README.md")).unwrap();
474 assert!(readme.contains("src/app.rs"));
475 assert!(!readme.contains("src/state.rs"));
476 assert!(!readme.contains("src/services/network.rs"));
477 }
478
479 #[test]
480 fn platform_selection_is_written_to_strict_config() {
481 let parent = tempfile::tempdir().unwrap();
482 let parent = Utf8Path::from_path(parent.path()).unwrap();
483 let mut android_request = request("weather");
484 android_request.platforms = PlatformSelection::Android;
485 let generated = ProjectGenerator::new(parent, android_request)
486 .generate()
487 .unwrap();
488 let config = FerryConfig::load(&generated.destination.join("ferry.toml")).unwrap();
489 assert_eq!(config.platforms, vec![TargetPlatform::Android]);
490 }
491
492 #[test]
493 fn runtime_dependency_sources_render_explicitly() {
494 let parent = tempfile::tempdir().unwrap();
495 let parent = Utf8Path::from_path(parent.path()).unwrap();
496
497 let mut registry = request("registry-runtime");
498 registry.runtime_dependency = RuntimeDependency::Registry("1.2.3".to_owned());
499 let registry = ProjectGenerator::new(parent, registry).generate().unwrap();
500 let registry_source = fs::read_to_string(registry.destination.join("Cargo.toml")).unwrap();
501 assert!(!registry_source.contains(parent.as_str()));
502 let registry_manifest = toml::from_str::<toml::Value>(®istry_source).unwrap();
503 let registry_dependency = registry_manifest
504 .get("dependencies")
505 .and_then(|dependencies| dependencies.get("rustferry"))
506 .unwrap();
507 assert!(
508 registry_manifest
509 .get("workspace")
510 .is_some_and(toml::Value::is_table)
511 );
512 assert_eq!(
513 registry_dependency
514 .get("version")
515 .and_then(toml::Value::as_str),
516 Some("=1.2.3")
517 );
518 assert!(registry_dependency.get("workspace").is_none());
519 assert!(registry_dependency.get("path").is_none());
520
521 let mut workspace = request("workspace-runtime");
522 workspace.runtime_dependency = RuntimeDependency::Workspace;
523 let workspace = ProjectGenerator::new(parent, workspace).generate().unwrap();
524 let workspace_source =
525 fs::read_to_string(workspace.destination.join("Cargo.toml")).unwrap();
526 let workspace_manifest = toml::from_str::<toml::Value>(&workspace_source).unwrap();
527 let workspace_dependency = workspace_manifest
528 .get("dependencies")
529 .and_then(|dependencies| dependencies.get("rustferry"))
530 .unwrap();
531 assert!(workspace_manifest.get("workspace").is_none());
532 assert_eq!(
533 workspace_dependency
534 .get("workspace")
535 .and_then(toml::Value::as_bool),
536 Some(true)
537 );
538 assert!(workspace_dependency.get("version").is_none());
539 assert!(workspace_dependency.get("path").is_none());
540
541 let runtime = parent.join("runtime with spaces");
542 let mut path = request("path-runtime");
543 path.runtime_dependency = RuntimeDependency::Path(runtime.clone());
544 let path = ProjectGenerator::new(parent, path).generate().unwrap();
545 let path_manifest = fs::read_to_string(path.destination.join("Cargo.toml")).unwrap();
546 let path_manifest = toml::from_str::<toml::Value>(&path_manifest).unwrap();
547 let path_dependency = path_manifest
548 .get("dependencies")
549 .and_then(|dependencies| dependencies.get("rustferry"))
550 .unwrap();
551 assert!(
552 path_manifest
553 .get("workspace")
554 .is_some_and(toml::Value::is_table)
555 );
556 assert_eq!(
557 path_dependency.get("path").and_then(toml::Value::as_str),
558 Some(runtime.as_str())
559 );
560 assert!(path_dependency.get("version").is_none());
561 assert!(path_dependency.get("workspace").is_none());
562 }
563
564 #[test]
565 fn explicit_display_name_is_written_without_changing_package_name() {
566 let parent = tempfile::tempdir().unwrap();
567 let parent = Utf8Path::from_path(parent.path()).unwrap();
568 let mut named = request("weather-client");
569 named.display_name = Some("Weather · Europe".to_owned());
570 let generated = ProjectGenerator::new(parent, named).generate().unwrap();
571 assert_eq!(generated.names.crate_name, "weather-client");
572 assert_eq!(generated.names.display_name, "Weather · Europe");
573 let config = FerryConfig::load(&generated.destination.join("ferry.toml")).unwrap();
574 assert_eq!(config.app.name, "Weather · Europe");
575
576 let mut invalid = request("invalid-display");
577 invalid.display_name = Some(" trailing ".to_owned());
578 assert!(matches!(
579 ProjectGenerator::new(parent, invalid).plan(),
580 Err(GenerationError::InvalidDisplayName { .. })
581 ));
582 }
583
584 #[test]
585 fn extension_templates_publish_state_and_use_the_configured_scheme() {
586 let parent = tempfile::tempdir().unwrap();
587 let parent = Utf8Path::from_path(parent.path()).unwrap();
588 for (template, file, expected_call) in [
589 (
590 TemplateKind::Widget,
591 "src/extensions/widget.rs",
592 "widgets::update",
593 ),
594 (
595 TemplateKind::LiveActivity,
596 "src/extensions/live_activity.rs",
597 "live_activity::start_with_snapshot",
598 ),
599 ] {
600 let mut extension_request = request(template.as_str());
601 extension_request.identifier = Some("com.example.brand".to_owned());
602 extension_request.template = template;
603 let generated = ProjectGenerator::new(parent, extension_request)
604 .generate()
605 .unwrap();
606 let extension = fs::read_to_string(generated.destination.join(file)).unwrap();
607 assert!(extension.contains(expected_call));
608 assert!(extension.contains("brand://"));
609 assert!(!extension.contains("{{"));
610 }
611 }
612
613 #[test]
614 fn kitchen_sink_exercises_its_clipboard_and_share_capabilities() {
615 let parent = tempfile::tempdir().unwrap();
616 let parent = Utf8Path::from_path(parent.path()).unwrap();
617 let mut kitchen_sink = request("kitchen-sink");
618 kitchen_sink.template = TemplateKind::KitchenSink;
619 let generated = ProjectGenerator::new(parent, kitchen_sink)
620 .generate()
621 .unwrap();
622 let app = fs::read_to_string(generated.destination.join("src/app.rs")).unwrap();
623 assert!(app.contains("rustferry::clipboard::write_text"));
624 assert!(app.contains("rustferry::clipboard::read_text"));
625 assert!(app.contains("rustferry::share::text"));
626 assert!(!app.contains("{{"));
627 }
628}