Skip to main content

rustferry_codegen/
project.rs

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/// Starter project flavor.
13#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
14pub enum TemplateKind {
15    /// Friendly, documented demonstration of core features.
16    #[default]
17    Starter,
18    /// Smallest real Slint application.
19    Minimal,
20    /// State and persistence example.
21    Counter,
22    /// Network status, offline UI, and backend probe example.
23    Network,
24    /// Local-notification permission and immediate-delivery example.
25    Notifications,
26    /// Home-screen widget example.
27    Widget,
28    /// iOS Live Activity with Android ongoing-notification fallback.
29    LiveActivity,
30    /// Regression and capability demonstration application.
31    KitchenSink,
32}
33
34impl TemplateKind {
35    /// Stable CLI spelling.
36    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/// Platforms configured in a new application.
51#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
52pub enum PlatformSelection {
53    /// Android only.
54    Android,
55    /// iOS only.
56    Ios,
57    /// Android and iOS from the same Rust source.
58    #[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/// How a generated project resolves the runtime crate.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub enum RuntimeDependency {
75    /// A crates.io version used by installed releases.
76    Registry(String),
77    /// Inherit `rustferry` from the containing Cargo workspace.
78    Workspace,
79    /// An explicit local source checkout used by development and tests.
80    Path(Utf8PathBuf),
81}
82
83/// Validated request for one generated application.
84#[derive(Clone, Debug)]
85pub struct ProjectRequest {
86    /// Directory/display/package name supplied by the user.
87    pub name: String,
88    /// Optional human-readable application name, independent of the directory/package name.
89    pub display_name: Option<String>,
90    /// Explicit application identifier, or a safe derived default.
91    pub identifier: Option<String>,
92    /// Base template plus capability fragments.
93    pub template: TemplateKind,
94    /// Target platform set.
95    pub platforms: PlatformSelection,
96    /// Runtime dependency resolution.
97    pub runtime_dependency: RuntimeDependency,
98}
99
100/// Side-effect-free description of generated output.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct GenerationPlan {
103    /// Final project directory.
104    pub destination: Utf8PathBuf,
105    /// Derived names used by Cargo and platform manifests.
106    pub names: ProjectNames,
107    /// Files created relative to the destination.
108    pub files: Vec<Utf8PathBuf>,
109}
110
111/// Successfully generated project metadata.
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub struct GeneratedProject {
114    /// Final project directory.
115    pub destination: Utf8PathBuf,
116    /// Derived names used by Cargo and platform manifests.
117    pub names: ProjectNames,
118    /// Files created relative to the destination.
119    pub files: Vec<Utf8PathBuf>,
120}
121
122/// Atomic application project generator.
123#[derive(Clone, Debug)]
124pub struct ProjectGenerator {
125    parent: Utf8PathBuf,
126    request: ProjectRequest,
127}
128
129impl ProjectGenerator {
130    /// Create a generator rooted at an existing parent directory.
131    pub fn new(parent: impl Into<Utf8PathBuf>, request: ProjectRequest) -> Self {
132        Self {
133            parent: parent.into(),
134            request,
135        }
136    }
137
138    /// Validate and list changes without touching the filesystem.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error when names or configuration are invalid, or a template
143    /// cannot be rendered.
144    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    /// Generate into a sibling temporary directory and rename only after every write succeeds.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error when validation, template rendering, filesystem writes,
163    /// or the final atomic rename fails.
164    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/// Project generation failure with its exact filesystem stage.
326#[derive(Debug, Error)]
327pub enum GenerationError {
328    /// Invalid project or application identifier.
329    #[error(transparent)]
330    Naming(#[from] rustferry_core::NamingError),
331    /// An explicit user-facing application name was empty or unsafe for platform metadata.
332    #[error("invalid display name: {message}")]
333    InvalidDisplayName {
334        /// Exact portable display-name requirement.
335        message: String,
336    },
337    /// Invalid generated configuration.
338    #[error(transparent)]
339    Config(#[from] rustferry_core::ConfigError),
340    /// Parent directory was missing or inaccessible.
341    #[error("could not access project parent {path}: {source}")]
342    ParentDirectory {
343        /// Parent path.
344        path: Utf8PathBuf,
345        /// Filesystem error.
346        #[source]
347        source: std::io::Error,
348    },
349    /// Destination inspection failed.
350    #[error("could not inspect destination {path}: {source}")]
351    Inspect {
352        /// Destination path.
353        path: Utf8PathBuf,
354        /// Filesystem error.
355        #[source]
356        source: std::io::Error,
357    },
358    /// Refuse to overwrite any existing destination.
359    #[error("destination already exists: {0}")]
360    DestinationExists(Utf8PathBuf),
361    /// Staging directory creation failed.
362    #[error("could not create a temporary project below {path}: {source}")]
363    TemporaryDirectory {
364        /// Parent path.
365        path: Utf8PathBuf,
366        /// Filesystem error.
367        #[source]
368        source: std::io::Error,
369    },
370    /// OS path could not be represented safely in CLI/config output.
371    #[error("path is not valid UTF-8: {0:?}")]
372    NonUtf8Path(std::path::PathBuf),
373    /// A built-in template attempted to escape the staging directory.
374    #[error("built-in template path is unsafe: {0}")]
375    UnsafeTemplatePath(Utf8PathBuf),
376    /// A directory or file could not be created.
377    #[error("could not write generated path {path}: {source}")]
378    Write {
379        /// Generated path.
380        path: Utf8PathBuf,
381        /// Filesystem error.
382        #[source]
383        source: std::io::Error,
384    },
385    /// All files were written but the atomic rename failed.
386    #[error("generated files remain at {staging}; could not move them to {destination}: {source}")]
387    Commit {
388        /// Recoverable staging directory.
389        staging: Utf8PathBuf,
390        /// Requested final directory.
391        destination: Utf8PathBuf,
392        /// Filesystem error.
393        #[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>(&registry_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}