Skip to main content

canic_host/install_root/
state.rs

1use crate::release_set::dfx_root;
2use serde::{Deserialize, Serialize};
3use std::{fs, path::Path, path::PathBuf};
4
5pub(super) const INSTALL_STATE_SCHEMA_VERSION: u32 = 1;
6const INSTALL_STATE_FILE: &str = "install-state.json";
7const CURRENT_FLEET_FILE: &str = "current-fleet";
8
9///
10/// InstallState
11///
12
13#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
14pub struct InstallState {
15    pub schema_version: u32,
16    #[serde(default = "default_fleet_name")]
17    pub fleet: String,
18    pub installed_at_unix_secs: u64,
19    pub network: String,
20    pub root_target: String,
21    pub root_canister_id: String,
22    pub root_build_target: String,
23    pub workspace_root: String,
24    pub dfx_root: String,
25    pub config_path: String,
26    pub release_set_manifest_path: String,
27}
28
29///
30/// FleetSummary
31///
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct FleetSummary {
35    pub name: String,
36    pub current: bool,
37    pub state: InstallState,
38}
39
40/// Read the persisted install state for one project/network when present.
41pub(super) fn read_install_state(
42    dfx_root: &Path,
43    network: &str,
44) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
45    if let Some(fleet) = read_selected_fleet_name(dfx_root, network)? {
46        return read_fleet_install_state(dfx_root, network, &fleet);
47    }
48
49    read_legacy_install_state(dfx_root, network)
50}
51
52/// Read a named fleet install state for one project/network when present.
53pub(super) fn read_fleet_install_state(
54    dfx_root: &Path,
55    network: &str,
56    fleet: &str,
57) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
58    validate_fleet_name(fleet)?;
59    let path = fleet_install_state_path(dfx_root, network, fleet);
60    if !path.is_file() {
61        return Ok(None);
62    }
63
64    let bytes = fs::read(&path)?;
65    let mut state: InstallState = serde_json::from_slice(&bytes)?;
66    if state.fleet.is_empty() {
67        state.fleet = fleet.to_string();
68    }
69    Ok(Some(state))
70}
71
72/// Read the install state for the discovered current project/network.
73pub fn read_current_install_state(
74    network: &str,
75) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
76    let dfx_root = dfx_root()?;
77    read_install_state(&dfx_root, network)
78}
79
80/// Read either a named fleet state or the selected current fleet state.
81pub fn read_current_or_fleet_install_state(
82    network: &str,
83    fleet: Option<&str>,
84) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
85    let dfx_root = dfx_root()?;
86    match fleet {
87        Some(fleet) => read_fleet_install_state(&dfx_root, network, fleet),
88        None => read_install_state(&dfx_root, network),
89    }
90}
91
92/// List installed fleets for the current project/network.
93pub fn list_current_fleets(network: &str) -> Result<Vec<FleetSummary>, Box<dyn std::error::Error>> {
94    let dfx_root = dfx_root()?;
95    list_fleets(&dfx_root, network)
96}
97
98/// List installed fleets for one project/network.
99pub(super) fn list_fleets(
100    dfx_root: &Path,
101    network: &str,
102) -> Result<Vec<FleetSummary>, Box<dyn std::error::Error>> {
103    let current = read_selected_fleet_name(dfx_root, network)?;
104    let mut fleets = Vec::new();
105    let dir = fleets_dir(dfx_root, network);
106    if dir.is_dir() {
107        for entry in fs::read_dir(&dir)? {
108            let entry = entry?;
109            let path = entry.path();
110            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
111                continue;
112            }
113            let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
114                continue;
115            };
116            if let Some(state) = read_fleet_install_state(dfx_root, network, name)? {
117                fleets.push(FleetSummary {
118                    name: name.to_string(),
119                    current: current.as_deref() == Some(name),
120                    state,
121                });
122            }
123        }
124    }
125
126    if fleets.is_empty()
127        && let Some(state) = read_legacy_install_state(dfx_root, network)?
128    {
129        fleets.push(FleetSummary {
130            name: state.fleet.clone(),
131            current: true,
132            state,
133        });
134    }
135
136    fleets.sort_by(|left, right| left.name.cmp(&right.name));
137    Ok(fleets)
138}
139
140/// Select one installed fleet as the current project/network default.
141pub fn select_current_fleet(
142    network: &str,
143    fleet: &str,
144) -> Result<InstallState, Box<dyn std::error::Error>> {
145    let dfx_root = dfx_root()?;
146    select_fleet(&dfx_root, network, fleet)
147}
148
149/// Select one installed fleet for one project/network.
150fn select_fleet(
151    dfx_root: &Path,
152    network: &str,
153    fleet: &str,
154) -> Result<InstallState, Box<dyn std::error::Error>> {
155    let Some(state) = read_fleet_install_state(dfx_root, network, fleet)?.or_else(|| {
156        matching_legacy_fleet_state(dfx_root, network, fleet)
157            .ok()
158            .flatten()
159    }) else {
160        return Err(format!("unknown fleet {fleet} on network {network}").into());
161    };
162    if fleet_install_state_path(dfx_root, network, fleet).is_file() {
163        write_current_fleet_name(dfx_root, network, fleet)?;
164    } else {
165        write_install_state(dfx_root, network, &state)?;
166    }
167    Ok(state)
168}
169
170/// Return the legacy project-local install state path for one network.
171#[must_use]
172fn install_state_path(dfx_root: &Path, network: &str) -> PathBuf {
173    dfx_root
174        .join(".canic")
175        .join(network)
176        .join(INSTALL_STATE_FILE)
177}
178
179/// Return the project-local state path for one named fleet.
180#[must_use]
181pub(super) fn fleet_install_state_path(dfx_root: &Path, network: &str, fleet: &str) -> PathBuf {
182    fleets_dir(dfx_root, network).join(format!("{fleet}.json"))
183}
184
185/// Return the project-local current-fleet pointer path for one network.
186#[must_use]
187pub(super) fn current_fleet_path(dfx_root: &Path, network: &str) -> PathBuf {
188    dfx_root
189        .join(".canic")
190        .join(network)
191        .join(CURRENT_FLEET_FILE)
192}
193
194// Return the directory that owns named fleet state files.
195fn fleets_dir(dfx_root: &Path, network: &str) -> PathBuf {
196    dfx_root.join(".canic").join(network).join("fleets")
197}
198
199// Persist the completed install state under the project-local `.canic` directory.
200pub(super) fn write_install_state(
201    dfx_root: &Path,
202    network: &str,
203    state: &InstallState,
204) -> Result<PathBuf, Box<dyn std::error::Error>> {
205    validate_fleet_name(&state.fleet)?;
206    let path = fleet_install_state_path(dfx_root, network, &state.fleet);
207    if let Some(parent) = path.parent() {
208        fs::create_dir_all(parent)?;
209    }
210    fs::write(&path, serde_json::to_vec_pretty(state)?)?;
211    write_current_fleet_name(dfx_root, network, &state.fleet)?;
212    Ok(path)
213}
214
215// Read a legacy single-slot install state when no named fleet pointer exists.
216fn read_legacy_install_state(
217    dfx_root: &Path,
218    network: &str,
219) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
220    let path = install_state_path(dfx_root, network);
221    if !path.is_file() {
222        return Ok(None);
223    }
224
225    let bytes = fs::read(&path)?;
226    let state: InstallState = serde_json::from_slice(&bytes)?;
227    if state.fleet.is_empty() {
228        return Err(format!(
229            "install state at {} is missing required fleet name; reinstall from a config with [fleet].name",
230            path.display()
231        )
232        .into());
233    }
234    Ok(Some(state))
235}
236
237// Return the legacy single-slot state only when it matches the requested fleet.
238fn matching_legacy_fleet_state(
239    dfx_root: &Path,
240    network: &str,
241    fleet: &str,
242) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
243    Ok(read_legacy_install_state(dfx_root, network)?.filter(|state| state.fleet == fleet))
244}
245
246// Read the selected fleet name for one project/network.
247fn read_selected_fleet_name(
248    dfx_root: &Path,
249    network: &str,
250) -> Result<Option<String>, Box<dyn std::error::Error>> {
251    let path = current_fleet_path(dfx_root, network);
252    if !path.is_file() {
253        return Ok(None);
254    }
255
256    let name = fs::read_to_string(path)?.trim().to_string();
257    validate_fleet_name(&name)?;
258    Ok(Some(name))
259}
260
261// Write the selected fleet name for one project/network.
262fn write_current_fleet_name(
263    dfx_root: &Path,
264    network: &str,
265    fleet: &str,
266) -> Result<(), Box<dyn std::error::Error>> {
267    validate_fleet_name(fleet)?;
268    let path = current_fleet_path(dfx_root, network);
269    if let Some(parent) = path.parent() {
270        fs::create_dir_all(parent)?;
271    }
272    fs::write(path, format!("{fleet}\n"))?;
273    Ok(())
274}
275
276// Return the serde default for legacy install-state records.
277const fn default_fleet_name() -> String {
278    String::new()
279}
280
281// Keep fleet names filesystem-safe and easy to type in commands.
282pub(super) fn validate_fleet_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
283    let valid = !name.is_empty()
284        && name
285            .bytes()
286            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
287    if valid {
288        Ok(())
289    } else {
290        Err(format!("invalid fleet name {name:?}; use letters, numbers, '-' or '_'").into())
291    }
292}