Skip to main content

linera_service/
project.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::{
5    io::Write,
6    path::{Path, PathBuf},
7    process::Command,
8};
9
10use anyhow::{ensure, Context, Result};
11use cargo_toml::Manifest;
12use convert_case::{Case, Casing};
13use current_platform::CURRENT_PLATFORM;
14use fs_err::File;
15use tracing::debug;
16
17/// A Linera application project on disk, rooted at a given directory.
18pub struct Project {
19    root: PathBuf,
20}
21
22impl Project {
23    /// Creates a new application project from the template, scaffolding its files.
24    pub fn create_new(
25        name: &str,
26        linera_root: Option<&Path>,
27        dir: Option<PathBuf>,
28    ) -> Result<Self> {
29        ensure!(
30            !name.contains(std::path::is_separator),
31            "Project name {name} should not contain path-separators",
32        );
33        let root = match dir {
34            Some(dir) => dir,
35            None => {
36                let root = PathBuf::from(name);
37                ensure!(
38                    !root.exists(),
39                    "Directory {} already exists",
40                    root.display(),
41                );
42                root
43            }
44        };
45        ensure!(
46            root.extension().is_none(),
47            "Project name {name} should not have a file extension",
48        );
49        debug!("Creating directory at {}", root.display());
50        fs_err::create_dir_all(&root)?;
51
52        debug!("Creating the source directory");
53        let source_directory = Self::create_source_directory(&root)?;
54
55        debug!("Creating the tests directory");
56        let test_directory = Self::create_test_directory(&root)?;
57
58        debug!("Initializing git repository");
59        Self::initialize_git_repository(&root)?;
60
61        debug!("Writing Cargo.toml");
62        Self::create_cargo_toml(&root, name, linera_root)?;
63
64        debug!("Writing rust-toolchain.toml");
65        Self::create_rust_toolchain(&root)?;
66
67        debug!("Writing state.rs");
68        Self::create_state_file(&source_directory, name)?;
69
70        debug!("Writing lib.rs");
71        Self::create_lib_file(&source_directory, name)?;
72
73        debug!("Writing contract.rs");
74        Self::create_contract_file(&source_directory, name)?;
75
76        debug!("Writing service.rs");
77        Self::create_service_file(&source_directory, name)?;
78
79        debug!("Writing single_chain.rs");
80        Self::create_test_file(&test_directory, name)?;
81
82        Ok(Self { root })
83    }
84
85    /// Opens an existing application project at the given root directory.
86    pub fn from_existing_project(root: &Path) -> Result<Self> {
87        let root = root.canonicalize().with_context(|| {
88            format!(
89                "Could not find project at {}. \
90                 Make sure the specified directory exists.",
91                root.display()
92            )
93        })?;
94        ensure!(
95            root.join("Cargo.toml").exists(),
96            "No Cargo.toml found at {}. \
97             The path must point to a Rust project directory.",
98            root.display()
99        );
100        Ok(Self { root })
101    }
102
103    /// Runs the unit and integration tests of an application.
104    pub fn test(&self) -> Result<()> {
105        let tests = Command::new("cargo")
106            .arg("test")
107            .args(["--target", CURRENT_PLATFORM])
108            .current_dir(&self.root)
109            .spawn()?
110            .wait()?;
111        ensure!(tests.success(), "tests failed");
112        Ok(())
113    }
114
115    /// Finds the workspace for a given crate. If the workspace
116    /// does not exist, returns the path of the crate.
117    fn workspace_root(&self) -> Result<&Path> {
118        let mut current_path = self.root.as_path();
119        loop {
120            let toml_path = current_path.join("Cargo.toml");
121            if toml_path.exists() {
122                let toml = Manifest::from_path(toml_path)?;
123                if toml.workspace.is_some() {
124                    return Ok(current_path);
125                }
126            }
127            match current_path.parent() {
128                None => {
129                    break;
130                }
131                Some(parent) => current_path = parent,
132            }
133        }
134        Ok(self.root.as_path())
135    }
136
137    fn create_source_directory(project_root: &Path) -> Result<PathBuf> {
138        let source_directory = project_root.join("src");
139        fs_err::create_dir_all(&source_directory)?;
140        Ok(source_directory)
141    }
142
143    fn create_test_directory(project_root: &Path) -> Result<PathBuf> {
144        let test_directory = project_root.join("tests");
145        fs_err::create_dir_all(&test_directory)?;
146        Ok(test_directory)
147    }
148
149    fn initialize_git_repository(project_root: &Path) -> Result<()> {
150        let output = Command::new("git")
151            .args([
152                "init",
153                project_root
154                    .to_str()
155                    .context("project name contains non UTF-8 characters")?,
156            ])
157            .output()?;
158
159        ensure!(
160            output.status.success(),
161            "failed to initialize git repository at {}",
162            project_root.display()
163        );
164
165        Self::write_string_to_file(&project_root.join(".gitignore"), "/target")
166    }
167
168    fn create_cargo_toml(
169        project_root: &Path,
170        project_name: &str,
171        linera_root: Option<&Path>,
172    ) -> Result<()> {
173        let toml_path = project_root.join("Cargo.toml");
174        let (linera_sdk_dep, linera_sdk_dev_dep) = Self::linera_sdk_dependencies(linera_root);
175        let binary_root_name = project_name.replace('-', "_");
176        let contract_binary_name = format!("{binary_root_name}_contract");
177        let service_binary_name = format!("{binary_root_name}_service");
178        let toml_contents = format!(
179            include_str!("../template/Cargo.toml.template"),
180            project_name = project_name,
181            contract_binary_name = contract_binary_name,
182            service_binary_name = service_binary_name,
183            linera_sdk_dep = linera_sdk_dep,
184            linera_sdk_dev_dep = linera_sdk_dev_dep,
185        );
186        Self::write_string_to_file(&toml_path, &toml_contents)
187    }
188
189    fn create_rust_toolchain(project_root: &Path) -> Result<()> {
190        Self::write_string_to_file(
191            &project_root.join("rust-toolchain.toml"),
192            include_str!("../template/rust-toolchain.toml.template"),
193        )
194    }
195
196    fn create_state_file(source_directory: &Path, project_name: &str) -> Result<()> {
197        let project_name = project_name.to_case(Case::Pascal);
198        let state_path = source_directory.join("state.rs");
199        let file_content = format!(
200            include_str!("../template/state.rs.template"),
201            project_name = project_name
202        );
203        Self::write_string_to_file(&state_path, &file_content)
204    }
205
206    fn create_lib_file(source_directory: &Path, project_name: &str) -> Result<()> {
207        let project_name = project_name.to_case(Case::Pascal);
208        let state_path = source_directory.join("lib.rs");
209        let file_content = format!(
210            include_str!("../template/lib.rs.template"),
211            project_name = project_name
212        );
213        Self::write_string_to_file(&state_path, &file_content)
214    }
215
216    fn create_contract_file(source_directory: &Path, name: &str) -> Result<()> {
217        let project_name = name.to_case(Case::Pascal);
218        let contract_path = source_directory.join("contract.rs");
219        let contract_contents = format!(
220            include_str!("../template/contract.rs.template"),
221            module_name = name.replace('-', "_"),
222            project_name = project_name
223        );
224        Self::write_string_to_file(&contract_path, &contract_contents)
225    }
226
227    fn create_service_file(source_directory: &Path, name: &str) -> Result<()> {
228        let project_name = name.to_case(Case::Pascal);
229        let service_path = source_directory.join("service.rs");
230        let service_contents = format!(
231            include_str!("../template/service.rs.template"),
232            module_name = name.replace('-', "_"),
233            project_name = project_name
234        );
235        Self::write_string_to_file(&service_path, &service_contents)
236    }
237
238    fn create_test_file(test_directory: &Path, name: &str) -> Result<()> {
239        let project_name = name.to_case(Case::Pascal);
240        let test_path = test_directory.join("single_chain.rs");
241        let test_contents = format!(
242            include_str!("../template/tests/single_chain.rs.template"),
243            project_name = name.replace('-', "_"),
244            project_abi = project_name,
245        );
246        Self::write_string_to_file(&test_path, &test_contents)
247    }
248
249    fn write_string_to_file(path: &Path, content: &str) -> Result<()> {
250        let mut file = File::create(path)?;
251        file.write_all(content.as_bytes())?;
252        Ok(())
253    }
254
255    /// Resolves [`linera_sdk`] and [`linera_views`] dependencies.
256    fn linera_sdk_dependencies(linera_root: Option<&Path>) -> (String, String) {
257        match linera_root {
258            Some(path) => Self::linera_sdk_testing_dependencies(path),
259            None => Self::linera_sdk_production_dependencies(),
260        }
261    }
262
263    /// Resolves [`linera_sdk`] and [`linera_views`] dependencies in testing mode.
264    fn linera_sdk_testing_dependencies(linera_root: &Path) -> (String, String) {
265        // We're putting the Cargo.toml file one level above the current directory.
266        let linera_root = PathBuf::from("..").join(linera_root);
267        let linera_sdk_path = linera_root.join("linera-sdk");
268        let linera_sdk_dep = format!(
269            "linera-sdk = {{ path = \"{}\" }}",
270            linera_sdk_path.display()
271        );
272        let linera_sdk_dev_dep = format!(
273            "linera-sdk = {{ path = \"{}\", features = [\"test\", \"wasmer\"] }}",
274            linera_sdk_path.display()
275        );
276        (linera_sdk_dep, linera_sdk_dev_dep)
277    }
278
279    /// Adds [`linera_sdk`] dependencies in production mode.
280    fn linera_sdk_production_dependencies() -> (String, String) {
281        let version = env!("CARGO_PKG_VERSION");
282        let linera_sdk_dep = format!("linera-sdk = \"{version}\"");
283        let linera_sdk_dev_dep = format!(
284            "linera-sdk = {{ version = \"{version}\", features = [\"test\", \"wasmer\"] }}"
285        );
286        (linera_sdk_dep, linera_sdk_dev_dep)
287    }
288
289    /// Builds the project's contract and service to Wasm, returning their bytecode paths.
290    pub fn build(&self, name: Option<String>) -> Result<(PathBuf, PathBuf), anyhow::Error> {
291        let name = match name {
292            Some(name) => name,
293            None => self.project_package_name()?.replace('-', "_"),
294        };
295        let contract_name = format!("{name}_contract");
296        let service_name = format!("{name}_service");
297        let cargo_build = Command::new("cargo")
298            .arg("build")
299            .arg("--release")
300            .args(["--target", "wasm32-unknown-unknown"])
301            .current_dir(&self.root)
302            .spawn()?
303            .wait()?;
304        ensure!(cargo_build.success(), "build failed");
305        let build_path = self
306            .workspace_root()?
307            .join("target/wasm32-unknown-unknown/release");
308        Ok((
309            build_path.join(contract_name).with_extension("wasm"),
310            build_path.join(service_name).with_extension("wasm"),
311        ))
312    }
313
314    fn project_package_name(&self) -> Result<String> {
315        let manifest = Manifest::from_path(self.cargo_toml_path())?;
316        let name = manifest
317            .package
318            .context("Cargo.toml is missing `[package]`")?
319            .name;
320        Ok(name)
321    }
322
323    fn cargo_toml_path(&self) -> PathBuf {
324        self.root.join("Cargo.toml")
325    }
326}