feat: use isproj-only project files and local settings

Closes #6
This commit is contained in:
jychoi4 committed 2026-11-06 14:46:00 +09:00
1 parent 3071e0d1ef
commit 916b79e5d6
12 files changed
+230 -40

No files matched your search

+37 -5
View File
@@ -58,12 +58,39 @@ fn atomic_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
temp.persist(path).map_err(|error| error.error)?;
Ok(())
}
pub fn is_project_path(path: &Path) -> bool {
path.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("isproj"))
&& !path.file_stem().is_some_and(|stem| {
Path::new(stem)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("isproj"))
})
}
fn check_project_path(path: &Path) -> Result<()> {
ensure!(is_project_path(path), "UnsupportedProjectExtension: .isproj 프로젝트만 지원합니다. 기존 .json 프로젝트는 지원하지 않습니다.");
Ok(())
}
pub fn new_project_path(path: &Path) -> Result<PathBuf> {
ensure!(path.file_name().is_some(), "MissingFilename");
let path = if path.extension().is_none() {
path.with_extension("isproj")
} else {
path.to_path_buf()
};
check_project_path(&path)?;
Ok(path)
}
pub fn appended_path(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(suffix);
PathBuf::from(name)
}
pub fn local_config_path(path: &Path) -> PathBuf {
appended_path(path, ".local.json")
}
pub fn open_project(path: &Path) -> Result<ProjectDocument> {
check_project_path(path)?;
reject_links(path)?;
let path = path.canonicalize()?;
let bytes = fs::read(&path)?;
@@ -73,7 +100,7 @@ pub fn open_project(path: &Path) -> Result<ProjectDocument> {
project.schema_version == 2,
"UnsupportedSchema: v2 프로젝트만 열 수 있습니다. 원본을 자동 변환하지 않습니다."
);
let local_path = path.parent().unwrap().join("package-project.local.json");
let local_path = local_config_path(&path);
reject_links(&local_path)?;
let local_bytes = if local_path.exists() {
fs::read(&local_path)?
@@ -112,8 +139,8 @@ pub fn save_project(document: &ProjectDocument) -> Result<ProjectDocument> {
);
// A draft may be incomplete, but never silently accept a different schema.
ensure!(document.project.schema_version == 2, "UnsupportedSchema");
let project_backup = document.path.with_extension("json.bak");
let local_path = document.path.parent().unwrap().join("package-project.local.json");
let project_backup = appended_path(&document.path, ".bak");
let local_path = local_config_path(&document.path);
let local_backup = appended_path(&local_path, ".bak");
for backup in [&project_backup, &local_backup] {
reject_links(backup)?;
@@ -141,12 +168,17 @@ pub fn save_project(document: &ProjectDocument) -> Result<ProjectDocument> {
open_project(&document.path)
}
pub fn create_project(path: &Path) -> Result<ProjectDocument> {
ensure!(path.file_name().is_some(), "MissingFilename");
let normalized = new_project_path(path)?;
let path = normalized.as_path();
reject_links(path)?;
fs::create_dir_all(path.parent().context("MissingParent")?)?;
let _lock = lock_project(path.parent().unwrap())?;
ensure!(
!local_config_path(path).exists(),
"LocalConfigAlreadyExists"
);
let mut value: serde_json::Value =
serde_json::from_str(include_str!("../../../fixtures/package-project.json"))?;
serde_json::from_str(include_str!("../../../fixtures/InstallerProject.isproj"))?;
value["formats"]["msix"]["identity"]["publisher"] =
serde_json::Value::String("CN=Example Publisher".into());
let mut file = File::options()
@@ -0,0 +1,145 @@
use package_infra as infra;
use std::fs;
#[test]
fn new_projects_normalize_extension_and_roundtrip_local_settings_and_backups() {
let temp = tempfile::tempdir().unwrap();
let mut doc = infra::create_project(&temp.path().join("한글 프로젝트")).unwrap();
assert!(doc.path.ends_with("한글 프로젝트.isproj"));
let original = fs::read(&doc.path).unwrap();
assert!(serde_json::from_slice::<serde_json::Value>(&original)
.unwrap()
.get("projectId")
.is_some());
doc.project.product.display_name = "변경된 제품".into();
doc.local
.sources
.insert("appPayload".into(), "payload".into());
doc.local.developer_certificate_thumbprint = Some("A".repeat(40));
let saved = infra::save_project(&doc).unwrap();
assert_eq!(saved.local.sources["appPayload"], "payload");
assert_eq!(
saved.local.developer_certificate_thumbprint,
doc.local.developer_certificate_thumbprint
);
assert_eq!(
saved.project.formats.msix.identity.name,
doc.project.formats.msix.identity.name
);
assert_eq!(
saved.project.formats.msix.identity.publisher,
doc.project.formats.msix.identity.publisher
);
assert_eq!(
fs::read(infra::appended_path(&doc.path, ".bak")).unwrap(),
original
);
let local_path = infra::local_config_path(&doc.path);
assert!(local_path.ends_with("한글 프로젝트.isproj.local.json"));
let local_bytes = fs::read(&local_path).unwrap();
infra::save_project(&saved).unwrap();
assert_eq!(
fs::read(infra::appended_path(&local_path, ".bak")).unwrap(),
local_bytes
);
assert!(!temp.path().join("한글 프로젝트.json.bak").exists());
}
#[test]
fn json_and_duplicate_extensions_are_rejected_even_with_valid_contents() {
let temp = tempfile::tempdir().unwrap();
let doc = infra::create_project(&temp.path().join("Sample.ISPROJ")).unwrap();
assert!(infra::open_project(&doc.path).is_ok());
for name in [
"legacy.json",
"bad.txt",
"duplicate.isproj.isproj",
"no-extension",
] {
let path = temp.path().join(name);
fs::copy(&doc.path, &path).unwrap();
assert!(infra::open_project(&path)
.unwrap_err()
.to_string()
.contains("UnsupportedProjectExtension"));
if name != "no-extension" {
assert!(infra::create_project(&path)
.unwrap_err()
.to_string()
.contains("UnsupportedProjectExtension"));
}
}
assert!(infra::create_project(&doc.path).is_err());
fs::write(temp.path().join("broken.isproj"), "not json").unwrap();
assert!(infra::open_project(&temp.path().join("broken.isproj")).is_err());
}
#[test]
fn no_legacy_local_fallback_and_no_overwrite_of_orphan_local_settings() {
let temp = tempfile::tempdir().unwrap();
fs::write(
temp.path().join("package-project.local.json"),
r#"{"sources":{"legacy":"do-not-load"},"makeAppx":null}"#,
)
.unwrap();
let doc = infra::create_project(&temp.path().join("fresh.isproj")).unwrap();
assert!(doc.local.sources.is_empty());
let path = temp.path().join("orphan.isproj");
fs::write(infra::local_config_path(&path), "keep").unwrap();
assert!(infra::create_project(&path)
.unwrap_err()
.to_string()
.contains("LocalConfigAlreadyExists"));
assert!(!path.exists());
}
#[test]
fn local_revision_conflicts_and_failed_backup_preserve_both_files() {
let temp = tempfile::tempdir().unwrap();
let doc = infra::create_project(&temp.path().join("sample.isproj")).unwrap();
let mut doc = infra::save_project(&doc).unwrap();
let local_path = infra::local_config_path(&doc.path);
let original = fs::read(&doc.path).unwrap();
let local = fs::read(&local_path).unwrap();
fs::write(&local_path, b"{\"sources\":{},\"makeAppx\":null}\n").unwrap();
assert!(infra::save_project(&doc)
.unwrap_err()
.to_string()
.contains("RevisionConflict"));
fs::write(&local_path, &local).unwrap();
fs::create_dir(infra::appended_path(&local_path, ".bak")).unwrap();
doc.project.product.display_name = "must not commit".into();
doc.local
.sources
.insert("appPayload".into(), "changed".into());
assert!(infra::save_project(&doc).is_err());
assert_eq!(fs::read(&doc.path).unwrap(), original);
assert_eq!(fs::read(&local_path).unwrap(), local);
}
#[cfg(windows)]
#[test]
fn failed_project_replacement_restores_previous_local_settings() {
use std::os::windows::fs::OpenOptionsExt;
let temp = tempfile::tempdir().unwrap();
let doc = infra::create_project(&temp.path().join("locked.isproj")).unwrap();
let mut doc = infra::save_project(&doc).unwrap();
let local_path = infra::local_config_path(&doc.path);
let project_before = fs::read(&doc.path).unwrap();
let local_before = fs::read(&local_path).unwrap();
// Permit reading and writing, but hold back FILE_SHARE_DELETE so replacement fails.
let _held = fs::OpenOptions::new()
.read(true)
.share_mode(3)
.open(&doc.path)
.unwrap();
doc.project.product.display_name = "must roll back".into();
doc.local
.sources
.insert("appPayload".into(), "changed".into());
assert!(infra::save_project(&doc).is_err());
assert_eq!(fs::read(&doc.path).unwrap(), project_before);
assert_eq!(fs::read(local_path).unwrap(), local_before);
}
+1 -1
View File
@@ -18,7 +18,7 @@ fn png(path: &Path, size: u32) {
}
fn fixture() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("package-project.json");
let path = temp.path().join("InstallerProject.isproj");
let mut doc = infra::create_project(&path).unwrap();
let payload = temp.path().join("payload");
fs::create_dir_all(payload.join("Assets")).unwrap();