Skip to main content

cli/data/import_v2/
state.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17
18use chrono::{DateTime, Utc};
19use fs2::FileExt;
20use serde::{Deserialize, Serialize};
21use snafu::{IntoError, OptionExt, ResultExt};
22use tokio::io::AsyncWriteExt;
23
24use crate::data::import_v2::error::{
25    ImportStateIoSnafu, ImportStateLockedSnafu, ImportStateParseSnafu, ImportStateUnknownTaskSnafu,
26    Result,
27};
28use crate::data::path::encode_path_segment;
29
30const IMPORT_STATE_ROOT: &str = ".greptime";
31const IMPORT_STATE_DIR: &str = "import_state";
32static IMPORT_STATE_TMP_ID: AtomicU64 = AtomicU64::new(0);
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub(crate) enum ImportTaskStatus {
37    Pending,
38    InProgress,
39    Completed,
40    Failed,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub(crate) struct ImportTaskKey {
45    pub(crate) chunk_id: u32,
46    pub(crate) schema: String,
47}
48
49impl ImportTaskKey {
50    pub(crate) fn new(chunk_id: u32, schema: impl Into<String>) -> Self {
51        Self {
52            chunk_id,
53            schema: schema.into(),
54        }
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub(crate) struct ImportTaskState {
60    pub(crate) chunk_id: u32,
61    pub(crate) schema: String,
62    pub(crate) status: ImportTaskStatus,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub(crate) error: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub(crate) struct ImportState {
69    pub(crate) snapshot_id: String,
70    pub(crate) target_addr: String,
71    pub(crate) catalog: String,
72    pub(crate) schemas: Vec<String>,
73    #[serde(default)]
74    pub(crate) ddl_completed: bool,
75    pub(crate) updated_at: DateTime<Utc>,
76    // Tasks are (chunk-schema) tuples and can reach the tens of thousands;
77    // linear scans here are accepted because per-task work is dominated by
78    // network I/O and an fsync, but if the bound grows further this should be
79    // backed by a HashMap<(chunk_id, schema), index> rebuilt after load.
80    pub(crate) tasks: Vec<ImportTaskState>,
81}
82
83impl ImportState {
84    pub(crate) fn new<I>(
85        snapshot_id: impl Into<String>,
86        target_addr: impl Into<String>,
87        catalog: impl Into<String>,
88        schemas: &[String],
89        tasks: I,
90    ) -> Self
91    where
92        I: IntoIterator<Item = ImportTaskKey>,
93    {
94        Self {
95            snapshot_id: snapshot_id.into(),
96            target_addr: target_addr.into(),
97            catalog: catalog.into(),
98            schemas: canonical_schema_selection(schemas),
99            ddl_completed: false,
100            updated_at: Utc::now(),
101            tasks: tasks
102                .into_iter()
103                .map(|task| ImportTaskState {
104                    chunk_id: task.chunk_id,
105                    schema: task.schema,
106                    status: ImportTaskStatus::Pending,
107                    error: None,
108                })
109                .collect(),
110        }
111    }
112
113    pub(crate) fn mark_ddl_completed(&mut self) {
114        self.ddl_completed = true;
115        self.updated_at = Utc::now();
116    }
117
118    pub(crate) fn task_status(&self, chunk_id: u32, schema: &str) -> Option<ImportTaskStatus> {
119        self.tasks
120            .iter()
121            .find(|task| task.chunk_id == chunk_id && task.schema == schema)
122            .map(|task| task.status)
123    }
124
125    pub(crate) fn set_task_status(
126        &mut self,
127        chunk_id: u32,
128        schema: &str,
129        status: ImportTaskStatus,
130        error: Option<String>,
131    ) -> Result<()> {
132        let task = self
133            .tasks
134            .iter_mut()
135            .find(|task| task.chunk_id == chunk_id && task.schema == schema)
136            .context(ImportStateUnknownTaskSnafu {
137                chunk_id,
138                schema: schema.to_string(),
139            })?;
140        task.status = status;
141        task.error = error;
142        self.updated_at = Utc::now();
143        Ok(())
144    }
145}
146
147#[derive(Debug)]
148pub(crate) struct ImportStateLockGuard {
149    file: std::fs::File,
150}
151
152impl Drop for ImportStateLockGuard {
153    fn drop(&mut self) {
154        let _ = self.file.unlock();
155    }
156}
157
158pub(crate) fn default_state_path(
159    snapshot_id: &str,
160    target_addr: &str,
161    catalog: &str,
162    schemas: &[String],
163) -> Option<PathBuf> {
164    let home = default_home_dir_with(|key| std::env::var_os(key));
165    let cwd = std::env::current_dir().ok();
166    default_state_path_with(
167        home.as_deref(),
168        cwd.as_deref(),
169        snapshot_id,
170        target_addr,
171        catalog,
172        schemas,
173    )
174}
175
176fn default_home_dir_with<F>(get: F) -> Option<PathBuf>
177where
178    F: Fn(&str) -> Option<std::ffi::OsString>,
179{
180    get("HOME")
181        .or_else(|| get("USERPROFILE"))
182        .map(PathBuf::from)
183        .or_else(|| {
184            let drive = get("HOMEDRIVE")?;
185            let path = get("HOMEPATH")?;
186            Some(PathBuf::from(drive).join(path))
187        })
188}
189
190fn default_state_path_with(
191    home: Option<&Path>,
192    cwd: Option<&Path>,
193    snapshot_id: &str,
194    target_addr: &str,
195    catalog: &str,
196    schemas: &[String],
197) -> Option<PathBuf> {
198    let file_name = import_state_file_name(snapshot_id, target_addr, catalog, schemas);
199    match (home, cwd) {
200        (Some(home), _) => Some(
201            home.join(IMPORT_STATE_ROOT)
202                .join(IMPORT_STATE_DIR)
203                .join(file_name),
204        ),
205        (None, Some(cwd)) => Some(cwd.join(file_name)),
206        (None, None) => None,
207    }
208}
209
210fn import_state_file_name(
211    snapshot_id: &str,
212    target_addr: &str,
213    catalog: &str,
214    schemas: &[String],
215) -> String {
216    format!(
217        ".import_state_{}_{}_{}.json",
218        encode_path_segment(snapshot_id),
219        encode_path_segment(target_addr),
220        import_identity_hash(catalog, schemas)
221    )
222}
223
224pub(crate) fn canonical_schema_selection(schemas: &[String]) -> Vec<String> {
225    let mut canonicalized = schemas
226        .iter()
227        .map(|schema| schema.to_ascii_lowercase())
228        .collect::<Vec<_>>();
229    canonicalized.sort();
230    canonicalized.dedup();
231    canonicalized
232}
233
234/// FNV-1a over `(catalog, schemas)`. The output is part of the persisted state
235/// filename, so we cannot use `std::collections::hash_map::DefaultHasher` -
236/// Rust does not guarantee its algorithm across releases, which would make a
237/// state file written by one toolchain undiscoverable by another.
238fn import_identity_hash(catalog: &str, schemas: &[String]) -> String {
239    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
240    const FNV_PRIME: u64 = 0x100000001b3;
241
242    fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 {
243        for byte in bytes {
244            hash ^= u64::from(*byte);
245            hash = hash.wrapping_mul(FNV_PRIME);
246        }
247        hash
248    }
249
250    let mut hash = FNV_OFFSET;
251    hash = hash_bytes(hash, catalog.as_bytes());
252    // 0xff cannot appear in valid UTF-8, so it works as an unambiguous
253    // field separator between adjacent identifiers.
254    hash = hash_bytes(hash, &[0xff]);
255    for schema in canonical_schema_selection(schemas) {
256        hash = hash_bytes(hash, schema.as_bytes());
257        hash = hash_bytes(hash, &[0xff]);
258    }
259    format!("{hash:016x}")
260}
261
262pub(crate) async fn load_import_state(path: &Path) -> Result<Option<ImportState>> {
263    match tokio::fs::read(path).await {
264        Ok(bytes) => {
265            let mut state: ImportState =
266                serde_json::from_slice(&bytes).context(ImportStateParseSnafu)?;
267            normalize_import_state_for_resume(&mut state);
268            Ok(Some(state))
269        }
270        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
271        Err(source) => Err(source).context(ImportStateIoSnafu {
272            path: path.display().to_string(),
273        }),
274    }
275}
276
277/// Caller must hold the lock acquired via `try_acquire_import_state_lock`.
278pub(crate) async fn save_import_state(path: &Path, state: &ImportState) -> Result<()> {
279    if let Some(parent) = path.parent() {
280        tokio::fs::create_dir_all(parent)
281            .await
282            .context(ImportStateIoSnafu {
283                path: parent.display().to_string(),
284            })?;
285    }
286
287    let bytes =
288        serde_json::to_vec_pretty(state).expect("ImportState should always be serializable");
289    let tmp_path = unique_tmp_path(path);
290    let mut file = tokio::fs::File::create(&tmp_path)
291        .await
292        .context(ImportStateIoSnafu {
293            path: tmp_path.display().to_string(),
294        })?;
295    file.write_all(&bytes).await.context(ImportStateIoSnafu {
296        path: tmp_path.display().to_string(),
297    })?;
298    file.sync_all().await.context(ImportStateIoSnafu {
299        path: tmp_path.display().to_string(),
300    })?;
301    // Close before rename; Windows forbids renaming an open file.
302    drop(file);
303
304    tokio::fs::rename(&tmp_path, path)
305        .await
306        .context(ImportStateIoSnafu {
307            path: path.display().to_string(),
308        })?;
309    sync_parent_dir(path).await?;
310    Ok(())
311}
312
313pub(crate) fn try_acquire_import_state_lock(path: &Path) -> Result<ImportStateLockGuard> {
314    if let Some(parent) = path.parent() {
315        std::fs::create_dir_all(parent).context(ImportStateIoSnafu {
316            path: parent.display().to_string(),
317        })?;
318    }
319
320    let lock_path = import_state_lock_path(path);
321    let file = std::fs::OpenOptions::new()
322        .create(true)
323        .read(true)
324        .write(true)
325        .truncate(false)
326        .open(&lock_path)
327        .context(ImportStateIoSnafu {
328            path: lock_path.display().to_string(),
329        })?;
330    file.try_lock_exclusive().map_err(|error| {
331        if is_lock_contention(&error) {
332            ImportStateLockedSnafu {
333                path: lock_path.display().to_string(),
334            }
335            .build()
336        } else {
337            ImportStateIoSnafu {
338                path: lock_path.display().to_string(),
339            }
340            .into_error(error)
341        }
342    })?;
343
344    Ok(ImportStateLockGuard { file })
345}
346
347fn is_lock_contention(error: &std::io::Error) -> bool {
348    error.kind() == std::io::ErrorKind::WouldBlock
349        || error.raw_os_error() == fs2::lock_contended_error().raw_os_error()
350}
351
352fn unique_tmp_path(path: &Path) -> PathBuf {
353    let pid = std::process::id();
354    let seq = IMPORT_STATE_TMP_ID.fetch_add(1, Ordering::Relaxed);
355    let file_name = path.file_name().unwrap_or_default().to_string_lossy();
356    path.with_file_name(format!("{file_name}.{pid}.{seq}.tmp"))
357}
358
359fn import_state_lock_path(path: &Path) -> PathBuf {
360    let file_name = path.file_name().unwrap_or_default().to_string_lossy();
361    path.with_file_name(format!("{file_name}.lock"))
362}
363
364fn normalize_import_state_for_resume(state: &mut ImportState) {
365    for task in &mut state.tasks {
366        if task.status == ImportTaskStatus::InProgress {
367            task.status = ImportTaskStatus::Pending;
368            task.error = None;
369        }
370    }
371}
372
373pub(crate) async fn delete_import_state(path: &Path) -> Result<()> {
374    match tokio::fs::remove_file(path).await {
375        Ok(()) => {
376            sync_parent_dir(path).await?;
377            Ok(())
378        }
379        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
380        Err(source) => Err(source).context(ImportStateIoSnafu {
381            path: path.display().to_string(),
382        }),
383    }
384}
385
386#[cfg(unix)]
387async fn sync_parent_dir(path: &Path) -> Result<()> {
388    let Some(parent) = path.parent() else {
389        return Ok(());
390    };
391
392    let dir = tokio::fs::File::open(parent)
393        .await
394        .context(ImportStateIoSnafu {
395            path: parent.display().to_string(),
396        })?;
397    dir.sync_all().await.context(ImportStateIoSnafu {
398        path: parent.display().to_string(),
399    })?;
400    Ok(())
401}
402
403#[cfg(not(unix))]
404async fn sync_parent_dir(_path: &Path) -> Result<()> {
405    Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410    use std::process::Command;
411
412    use chrono::Utc;
413    use tempfile::tempdir;
414
415    use super::*;
416
417    const CHILD_LOCK_PATH_ENV: &str = "GREPTIME_IMPORT_STATE_LOCK_PATH";
418    const CHILD_LOCK_TEST: &str =
419        "data::import_v2::state::tests::test_try_acquire_import_state_lock_child_process";
420
421    fn schemas() -> Vec<String> {
422        vec!["public".to_string(), "analytics".to_string()]
423    }
424
425    fn tasks() -> Vec<ImportTaskKey> {
426        vec![
427            ImportTaskKey::new(1, "public"),
428            ImportTaskKey::new(2, "analytics"),
429        ]
430    }
431
432    #[test]
433    fn test_import_state_new_initializes_pending_tasks() {
434        let state = ImportState::new(
435            "snapshot-1",
436            "127.0.0.1:4000",
437            "greptime",
438            &schemas(),
439            tasks(),
440        );
441
442        assert_eq!(state.snapshot_id, "snapshot-1");
443        assert_eq!(state.target_addr, "127.0.0.1:4000");
444        assert_eq!(state.catalog, "greptime");
445        assert_eq!(state.schemas, vec!["analytics", "public"]);
446        assert_eq!(state.tasks.len(), 2);
447        assert_eq!(state.tasks[0].status, ImportTaskStatus::Pending);
448        assert_eq!(state.tasks[1].status, ImportTaskStatus::Pending);
449    }
450
451    #[test]
452    fn test_set_task_status_updates_timestamp_and_error() {
453        let mut state = ImportState::new(
454            "snapshot-1",
455            "127.0.0.1:4000",
456            "greptime",
457            &schemas(),
458            tasks(),
459        );
460        let before = state.updated_at;
461        state.updated_at = Utc::now() - chrono::Duration::seconds(10);
462
463        state
464            .set_task_status(
465                1,
466                "public",
467                ImportTaskStatus::Failed,
468                Some("timeout".to_string()),
469            )
470            .unwrap();
471        assert_eq!(
472            state.task_status(1, "public"),
473            Some(ImportTaskStatus::Failed)
474        );
475        assert_eq!(state.tasks[0].error.as_deref(), Some("timeout"));
476        assert!(state.updated_at > before);
477    }
478
479    #[test]
480    fn test_set_task_status_rejects_unknown_task() {
481        let mut state = ImportState::new(
482            "snapshot-1",
483            "127.0.0.1:4000",
484            "greptime",
485            &schemas(),
486            tasks(),
487        );
488
489        let error = state
490            .set_task_status(99, "public", ImportTaskStatus::Completed, None)
491            .unwrap_err();
492
493        assert!(matches!(
494            error,
495            crate::data::import_v2::error::Error::ImportStateUnknownTask { chunk_id, schema, .. }
496                if chunk_id == 99 && schema == "public"
497        ));
498    }
499
500    #[tokio::test]
501    async fn test_save_and_load_import_state_round_trip() {
502        let dir = tempdir().unwrap();
503        let path = dir.path().join("import_state.json");
504        let mut state = ImportState::new(
505            "snapshot-1",
506            "127.0.0.1:4000",
507            "greptime",
508            &schemas(),
509            tasks(),
510        );
511        state
512            .set_task_status(2, "analytics", ImportTaskStatus::Completed, None)
513            .unwrap();
514
515        save_import_state(&path, &state).await.unwrap();
516        let loaded = load_import_state(&path).await.unwrap().unwrap();
517
518        assert_eq!(loaded.snapshot_id, state.snapshot_id);
519        assert_eq!(loaded.target_addr, state.target_addr);
520        assert_eq!(loaded.catalog, state.catalog);
521        assert_eq!(loaded.schemas, state.schemas);
522        assert_eq!(loaded.tasks, state.tasks);
523    }
524
525    #[tokio::test]
526    async fn test_save_import_state_overwrites_existing_file() {
527        let dir = tempdir().unwrap();
528        let path = dir.path().join("import_state.json");
529        let mut state = ImportState::new(
530            "snapshot-1",
531            "127.0.0.1:4000",
532            "greptime",
533            &schemas(),
534            tasks(),
535        );
536        save_import_state(&path, &state).await.unwrap();
537
538        state
539            .set_task_status(1, "public", ImportTaskStatus::Completed, None)
540            .unwrap();
541        save_import_state(&path, &state).await.unwrap();
542
543        let loaded = load_import_state(&path).await.unwrap().unwrap();
544        assert_eq!(
545            loaded.task_status(1, "public"),
546            Some(ImportTaskStatus::Completed)
547        );
548    }
549
550    #[test]
551    fn test_load_import_state_resets_in_progress_to_pending() {
552        let mut state = ImportState::new(
553            "snapshot-1",
554            "127.0.0.1:4000",
555            "greptime",
556            &schemas(),
557            tasks(),
558        );
559        state
560            .set_task_status(
561                2,
562                "analytics",
563                ImportTaskStatus::InProgress,
564                Some("running".to_string()),
565            )
566            .unwrap();
567
568        normalize_import_state_for_resume(&mut state);
569
570        assert_eq!(
571            state.task_status(1, "public"),
572            Some(ImportTaskStatus::Pending)
573        );
574        assert_eq!(
575            state.task_status(2, "analytics"),
576            Some(ImportTaskStatus::Pending)
577        );
578        assert_eq!(state.tasks[1].error, None);
579    }
580
581    #[test]
582    fn test_unique_tmp_path_generates_distinct_paths() {
583        let dir = tempdir().unwrap();
584        let path = dir.path().join("import_state.json");
585
586        let first = unique_tmp_path(&path);
587        let second = unique_tmp_path(&path);
588
589        assert_ne!(first, second);
590        assert!(first.starts_with(dir.path()));
591        assert!(second.starts_with(dir.path()));
592        assert!(
593            first
594                .file_name()
595                .unwrap()
596                .to_string_lossy()
597                .ends_with(".tmp")
598        );
599        assert!(
600            second
601                .file_name()
602                .unwrap()
603                .to_string_lossy()
604                .ends_with(".tmp")
605        );
606    }
607
608    #[test]
609    fn test_lock_contention_detection_accepts_platform_error() {
610        let error = fs2::lock_contended_error();
611
612        assert!(is_lock_contention(&error));
613    }
614
615    #[test]
616    fn test_try_acquire_import_state_lock_rejects_second_holder() {
617        let dir = tempdir().unwrap();
618        let path = dir.path().join("import_state.json");
619
620        let _first = try_acquire_import_state_lock(&path).unwrap();
621        // Import state locking guards concurrent CLI processes, so validate cross-process exclusion.
622        let output = Command::new(std::env::current_exe().unwrap())
623            .arg(CHILD_LOCK_TEST)
624            .arg("--ignored")
625            .arg("--exact")
626            .env(CHILD_LOCK_PATH_ENV, &path)
627            .output()
628            .unwrap();
629
630        assert!(
631            output.status.success(),
632            "child lock test failed\nstdout:\n{}\nstderr:\n{}",
633            String::from_utf8_lossy(&output.stdout),
634            String::from_utf8_lossy(&output.stderr)
635        );
636        let stdout = String::from_utf8_lossy(&output.stdout);
637        assert!(
638            stdout.contains("1 passed"),
639            "child lock test did not run the expected ignored test\nstdout:\n{stdout}"
640        );
641    }
642
643    #[test]
644    #[ignore = "spawned by test_try_acquire_import_state_lock_rejects_second_holder"]
645    fn test_try_acquire_import_state_lock_child_process() {
646        let path = std::env::var_os(CHILD_LOCK_PATH_ENV)
647            .expect("child lock path must be set by the parent test");
648        let path = PathBuf::from(path);
649        let error = try_acquire_import_state_lock(&path).unwrap_err();
650
651        assert!(matches!(
652            error,
653            crate::data::import_v2::error::Error::ImportStateLocked { .. }
654        ));
655    }
656
657    #[tokio::test]
658    async fn test_delete_import_state_ignores_missing_file() {
659        let dir = tempdir().unwrap();
660        let path = dir.path().join("missing.json");
661
662        delete_import_state(&path).await.unwrap();
663    }
664
665    #[test]
666    fn test_default_state_path_prefers_home_and_encodes_snapshot_id() {
667        let home = tempdir().unwrap();
668        let cwd = tempdir().unwrap();
669
670        let path = default_state_path_with(
671            Some(home.path()),
672            Some(cwd.path()),
673            "../snapshot",
674            "127.0.0.1:4000",
675            "greptime",
676            &schemas(),
677        )
678        .unwrap();
679
680        assert_eq!(
681            path.parent().unwrap(),
682            home.path().join(IMPORT_STATE_ROOT).join(IMPORT_STATE_DIR)
683        );
684        let file_name = path.file_name().unwrap().to_string_lossy();
685        assert!(file_name.starts_with(".import_state_%2E%2E%2Fsnapshot_127%2E0%2E0%2E1%3A4000_"));
686        assert!(file_name.ends_with(".json"));
687    }
688
689    #[test]
690    fn test_default_state_path_falls_back_to_cwd_when_home_missing() {
691        let cwd = tempdir().unwrap();
692
693        let path = default_state_path_with(
694            None,
695            Some(cwd.path()),
696            "snapshot-1",
697            "target-a",
698            "greptime",
699            &schemas(),
700        )
701        .unwrap();
702
703        assert_eq!(path.parent().unwrap(), cwd.path());
704        let file_name = path.file_name().unwrap().to_string_lossy();
705        assert!(file_name.starts_with(".import_state_snapshot-1_target-a_"));
706        assert!(file_name.ends_with(".json"));
707    }
708
709    #[test]
710    fn test_default_state_path_isolated_by_target_addr() {
711        let cwd = tempdir().unwrap();
712
713        let first = default_state_path_with(
714            None,
715            Some(cwd.path()),
716            "snapshot-1",
717            "127.0.0.1:4000",
718            "greptime",
719            &schemas(),
720        )
721        .unwrap();
722        let second = default_state_path_with(
723            None,
724            Some(cwd.path()),
725            "snapshot-1",
726            "127.0.0.1:4001",
727            "greptime",
728            &schemas(),
729        )
730        .unwrap();
731
732        assert_ne!(first, second);
733    }
734
735    #[test]
736    fn test_default_state_path_isolated_by_catalog_and_schemas() {
737        let cwd = tempdir().unwrap();
738        let public_only = vec!["public".to_string()];
739        let analytics_only = vec!["analytics".to_string()];
740
741        let first = default_state_path_with(
742            None,
743            Some(cwd.path()),
744            "snapshot-1",
745            "127.0.0.1:4000",
746            "greptime",
747            &public_only,
748        )
749        .unwrap();
750        let second = default_state_path_with(
751            None,
752            Some(cwd.path()),
753            "snapshot-1",
754            "127.0.0.1:4000",
755            "other",
756            &public_only,
757        )
758        .unwrap();
759        let third = default_state_path_with(
760            None,
761            Some(cwd.path()),
762            "snapshot-1",
763            "127.0.0.1:4000",
764            "greptime",
765            &analytics_only,
766        )
767        .unwrap();
768
769        assert_ne!(first, second);
770        assert_ne!(first, third);
771    }
772
773    #[test]
774    fn test_default_home_dir_prefers_home() {
775        let detected = default_home_dir_with(|key| match key {
776            "HOME" => Some(std::ffi::OsString::from("/tmp/home")),
777            "USERPROFILE" => Some(std::ffi::OsString::from("/tmp/userprofile")),
778            _ => None,
779        });
780
781        assert_eq!(detected, Some(PathBuf::from("/tmp/home")));
782    }
783
784    #[test]
785    fn test_default_home_dir_falls_back_to_userprofile() {
786        let detected = default_home_dir_with(|key| match key {
787            "USERPROFILE" => Some(std::ffi::OsString::from("/tmp/userprofile")),
788            _ => None,
789        });
790
791        assert_eq!(detected, Some(PathBuf::from("/tmp/userprofile")));
792    }
793
794    #[test]
795    fn test_default_home_dir_falls_back_to_home_drive_and_path() {
796        let detected = default_home_dir_with(|key| match key {
797            "HOMEDRIVE" => Some(std::ffi::OsString::from("/tmp")),
798            "HOMEPATH" => Some(std::ffi::OsString::from("windows-home")),
799            _ => None,
800        });
801
802        assert_eq!(detected, Some(PathBuf::from("/tmp").join("windows-home")));
803    }
804}