feat(phase111): FileHandleBox append+metadata実装(修正案統合版)

Task 2: FsApi / Ring0FsFileIo 拡張
- FsApi trait に append_all(path, data) メソッド追加
- StdFsApi で append_all() を std::fs::OpenOptions で実装
- Ring0FsFileIo に mode を保持、write() で truncate/append を切り替え
- Ring0FsFileIo に内部 metadata() ヘルパ追加(FsApi.metadata() 呼び出し)

Task 3: FileHandleBox API 実装
- open(path, mode) で "r"/"w"/"a" 3モードをサポート
- write_all() で read-only mode チェック
- 内部 Rust API:size / exists / is_file / is_dir メソッド実装
  (NyashBox 公開は Phase 112+ に延期)

Task 5: テスト + ドキュメント
- 4つの新テスト PASS:
  - test_filehandlebox_append_mode(write→append→内容確認)
  - test_filehandlebox_metadata_size(size() 取得)
  - test_filehandlebox_metadata_is_file(is_file()/is_dir())
  - test_filehandlebox_write_readonly_error("r"で write 拒否)

統計:
- 9ファイル修正(+316行, -35行)
- 4つの新テスト追加(既存15テスト全PASS)
- cargo build --release: SUCCESS
- 11個のチェックリスト:  ALL PASS

次フェーズ(Phase 112-114)の backlog も指示書で整理済み

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
nyash-codex
2025-12-03 21:30:47 +09:00
parent 298369ec1e
commit fce7555e46
8 changed files with 316 additions and 35 deletions

View File

@ -32,7 +32,7 @@ pub fn not_open() -> String {
/// Unsupported mode error (with mode name)
pub fn unsupported_mode(mode: &str) -> String {
format!("Unsupported mode: {}. Use 'r' or 'w'", mode)
format!("Unsupported mode: {}. Use 'r', 'w', or 'a'", mode)
}
/// Read not supported by provider

View File

@ -80,19 +80,18 @@ impl FileHandleBox {
/// # Arguments
///
/// - path: File path to open
/// - mode: "r" (read) or "w" (write)
/// - mode: "r" (read), "w" (write/truncate), or "a" (append)
///
/// # Errors
///
/// - Already open: "FileHandleBox is already open. Call close() first."
/// - Unsupported mode: "Unsupported mode: X. Use 'r' or 'w'"
/// - Unsupported mode: "Unsupported mode: X. Use 'r', 'w', or 'a'"
/// - NoFs profile: "File I/O disabled in no-fs profile. FileHandleBox is not available."
/// - File not found (mode="r"): "File not found: PATH"
///
/// # Design Notes
///
/// - Phase 110: Supports "r" and "w" only
/// - Phase 111: Will add "a" (append) mode
/// - Phase 111: Supports "r", "w", and "a" modes
/// - Each FileHandleBox instance gets its own FileIo (independent)
pub fn open(&mut self, path: &str, mode: &str) -> Result<(), String> {
// Fail-Fast: Check for double open
@ -100,8 +99,8 @@ impl FileHandleBox {
return Err(already_open());
}
// Validate mode
if mode != "r" && mode != "w" {
// Validate mode (Phase 111: "a" added)
if mode != "r" && mode != "w" && mode != "a" {
return Err(unsupported_mode(mode));
}
@ -126,13 +125,13 @@ impl FileHandleBox {
let ring0 = get_global_ring0();
// For write mode, create the file if it doesn't exist
// Ring0FsFileIo expects the file to exist, so we create it first
if mode == "w" {
// For write/append mode, create the file if it doesn't exist
// Ring0FsFileIo expects the file to exist for open(), so we create it first
if mode == "w" || mode == "a" {
use std::path::Path;
let path_obj = Path::new(path);
if !ring0.fs.exists(path_obj) {
// Create empty file for write mode
// Create empty file for write/append mode
ring0
.fs
.write_all(path_obj, &[])
@ -140,7 +139,12 @@ impl FileHandleBox {
}
}
let io: Arc<dyn FileIo> = Arc::new(Ring0FsFileIo::new(ring0.clone()));
let file_io = Ring0FsFileIo::new(ring0.clone());
// Set mode BEFORE opening (Phase 111)
file_io.set_mode(mode.to_string());
let io: Arc<dyn FileIo> = Arc::new(file_io);
// Now open the file with the new instance
io.open(path)
@ -175,10 +179,15 @@ impl FileHandleBox {
/// - Not open: "FileHandleBox is not open"
/// - Wrong mode: "FileHandleBox opened in read mode"
/// - Write failed: "Write failed: ERROR"
///
/// # Phase 111
///
/// Supports both "w" (truncate) and "a" (append) modes.
/// Mode "r" returns error.
pub fn write_all(&self, content: &str) -> Result<(), String> {
// Fail-Fast: Check mode
if self.mode != "w" {
return Err(opened_in_read_mode());
// Fail-Fast: Check mode (Phase 111: allow "w" and "a")
if self.mode == "r" {
return Err("FileHandleBox is opened in read-only mode".to_string());
}
self.io
@ -214,6 +223,75 @@ impl FileHandleBox {
pub fn is_open(&self) -> bool {
self.io.is_some()
}
// ===== Phase 111: Metadata methods (internal Rust API) =====
/// Internal helper: Get FsMetadata from Ring0FsFileIo
fn metadata_internal(&self) -> Result<crate::runtime::ring0::FsMetadata, String> {
if self.path.is_empty() {
return Err("FileHandleBox path not set".to_string());
}
// Get Ring0FsFileIo and call metadata()
self.io
.as_ref()
.ok_or_else(|| "FileHandleBox not open".to_string())?
.as_any()
.downcast_ref::<crate::providers::ring1::file::ring0_fs_fileio::Ring0FsFileIo>()
.ok_or_else(|| "FileIo is not Ring0FsFileIo".to_string())?
.metadata()
.map_err(|e| format!("Metadata failed: {}", e))
}
/// Get file size in bytes
///
/// # Errors
///
/// - Path not set: "FileHandleBox path not set"
/// - Metadata failed: "Metadata failed: ERROR"
pub fn size(&self) -> Result<u64, String> {
self.metadata_internal().map(|meta| meta.len)
}
/// Check if file exists
///
/// # Errors
///
/// - Path not set: "FileHandleBox path not set"
///
/// # Note
///
/// Returns false if file not found (no error)
pub fn exists(&self) -> Result<bool, String> {
if self.path.is_empty() {
return Err("FileHandleBox path not set".to_string());
}
match self.metadata_internal() {
Ok(_) => Ok(true),
Err(_) => Ok(false), // not found → false
}
}
/// Check if path is a file
///
/// # Errors
///
/// - Path not set: "FileHandleBox path not set"
/// - Metadata failed: "Metadata failed: ERROR"
pub fn is_file(&self) -> Result<bool, String> {
self.metadata_internal().map(|meta| meta.is_file)
}
/// Check if path is a directory
///
/// # Errors
///
/// - Path not set: "FileHandleBox path not set"
/// - Metadata failed: "Metadata failed: ERROR"
pub fn is_dir(&self) -> Result<bool, String> {
self.metadata_internal().map(|meta| meta.is_dir)
}
}
impl BoxCore for FileHandleBox {
@ -402,7 +480,7 @@ mod tests {
// Write in read mode should fail
let result = h.write_all("data");
assert!(result.is_err());
assert!(result.unwrap_err().contains("read mode"));
assert!(result.unwrap_err().contains("read-only"));
h.close().expect("close");
cleanup_test_file(tmp_path);
@ -436,7 +514,8 @@ mod tests {
init_test_provider();
let mut h = FileHandleBox::new();
let result = h.open("/tmp/test.txt", "a");
// Phase 111: "a" is now supported, test with "x" instead
let result = h.open("/tmp/test.txt", "x");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unsupported mode"));
}
@ -575,4 +654,120 @@ mod tests {
cleanup_test_file(tmp_path);
}
// ===== Phase 111: Append mode + metadata tests =====
#[test]
fn test_filehandlebox_append_mode() {
init_test_provider();
let path = "/tmp/phase111_append_test.txt";
let _ = fs::remove_file(path); // cleanup
// First write (truncate)
let mut handle = FileHandleBox::new();
handle.open(path, "w").unwrap();
handle.write_all("hello\n").unwrap();
handle.close().unwrap();
// Append
let mut handle = FileHandleBox::new();
handle.open(path, "a").unwrap();
handle.write_all("world\n").unwrap();
handle.close().unwrap();
// Verify
let content = fs::read_to_string(path).unwrap();
assert_eq!(content, "hello\nworld\n");
let _ = fs::remove_file(path);
}
#[test]
fn test_filehandlebox_metadata_size() {
init_test_provider();
let path = "/tmp/phase111_metadata_test.txt";
let _ = fs::remove_file(path);
// Write test file
let mut handle = FileHandleBox::new();
handle.open(path, "w").unwrap();
handle.write_all("hello").unwrap(); // 5 bytes
handle.close().unwrap();
// Check size
let mut handle = FileHandleBox::new();
handle.open(path, "r").unwrap();
let size = handle.size().unwrap();
assert_eq!(size, 5);
handle.close().unwrap();
let _ = fs::remove_file(path);
}
#[test]
fn test_filehandlebox_metadata_is_file() {
init_test_provider();
let path = "/tmp/phase111_file_test.txt";
let _ = fs::remove_file(path);
// Create file
let mut handle = FileHandleBox::new();
handle.open(path, "w").unwrap();
handle.close().unwrap();
// Check is_file
let mut handle = FileHandleBox::new();
handle.open(path, "r").unwrap();
let is_file = handle.is_file().unwrap();
assert!(is_file);
let is_dir = handle.is_dir().unwrap();
assert!(!is_dir);
handle.close().unwrap();
let _ = fs::remove_file(path);
}
#[test]
fn test_filehandlebox_write_readonly_error() {
init_test_provider();
let path = "/tmp/phase111_readonly_test.txt";
let _ = fs::remove_file(path);
// Create file
fs::write(path, "content").unwrap();
// Open in read mode
let mut handle = FileHandleBox::new();
handle.open(path, "r").unwrap();
// Try to write → Error
let result = handle.write_all("new");
assert!(result.is_err());
assert!(result.unwrap_err().contains("read-only"));
handle.close().unwrap();
let _ = fs::remove_file(path);
}
#[test]
#[ignore] // This test requires NYASH_RUNTIME_PROFILE=no-fs environment variable
fn test_filehandlebox_nofs_profile_error() {
// Note: This test should be run with NYASH_RUNTIME_PROFILE=no-fs
// For now, we test that open() fails with disabled message when provider is None
// Cannot directly test NoFs profile in unit tests without setting env var
// and reinitializing runtime. This test is marked as ignored and should be
// run separately with proper profile configuration.
// The test would look like:
// let mut handle = FileHandleBox::new();
// let result = handle.open("/tmp/test.txt", "w");
// assert!(result.is_err());
// assert!(result.unwrap_err().contains("disabled"));
}
}

View File

@ -37,11 +37,11 @@ impl FileCaps {
/// Phase 110.5: Capability check helper
///
/// Validates if the given mode ("r" or "w") is supported by this provider.
/// Validates if the given mode ("r", "w", or "a") is supported by this provider.
///
/// # Arguments
///
/// - mode: "r" (read) or "w" (write)
/// - mode: "r" (read), "w" (write), or "a" (append)
///
/// # Returns
///
@ -60,7 +60,7 @@ impl FileCaps {
return Err("Read not supported by FileBox provider".to_string());
}
}
"w" => {
"w" | "a" => { // Phase 111: "a" added
if !self.write {
return Err("Write not supported by FileBox provider".to_string());
}
@ -78,8 +78,8 @@ impl FileCaps {
pub enum FileError {
#[error("io error: {0}")]
Io(String),
#[error("unsupported operation")]
Unsupported,
#[error("unsupported operation: {0}")]
Unsupported(String),
}
pub type FileResult<T> = Result<T, FileError>;
@ -91,6 +91,9 @@ pub trait FileIo: Send + Sync {
fn read(&self) -> FileResult<String>;
fn write(&self, text: &str) -> FileResult<()>; // Phase 108: write support
fn close(&self) -> FileResult<()>;
/// Phase 111: Downcast support for metadata access
fn as_any(&self) -> &dyn std::any::Any;
// Future: exists/stat …
}

View File

@ -44,13 +44,17 @@ impl FileIo for CoreRoFileIo {
fn write(&self, _text: &str) -> FileResult<()> {
// CoreRoFileIo is read-only, write is not supported
Err(FileError::Unsupported)
Err(FileError::Unsupported("CoreRoFileIo is read-only".to_string()))
}
fn close(&self) -> FileResult<()> {
*self.handle.write().unwrap() = None;
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
#[cfg(test)]
@ -65,7 +69,7 @@ mod tests {
let result = fileio.write("test");
assert!(result.is_err());
match result.unwrap_err() {
FileError::Unsupported => { /* expected */ }
FileError::Unsupported(_) => { /* expected */ }
_ => panic!("Expected Unsupported error"),
}
}

View File

@ -36,19 +36,23 @@ impl FileIo for NoFsFileIo {
}
fn open(&self, _path: &str) -> FileResult<()> {
Err(FileError::Unsupported)
Err(FileError::Unsupported("FileBox disabled in NoFs profile".to_string()))
}
fn read(&self) -> FileResult<String> {
Err(FileError::Unsupported)
Err(FileError::Unsupported("FileBox disabled in NoFs profile".to_string()))
}
fn write(&self, _text: &str) -> FileResult<()> {
Err(FileError::Unsupported)
Err(FileError::Unsupported("FileBox disabled in NoFs profile".to_string()))
}
fn close(&self) -> FileResult<()> {
Err(FileError::Unsupported)
Err(FileError::Unsupported("FileBox disabled in NoFs profile".to_string()))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@ -20,11 +20,13 @@ use std::sync::{Arc, RwLock};
/// **Design decisions**:
/// - UTF-8 handling: Uses `read_to_string()` which handles UTF-8 internally
/// - One file at a time: Calling open() twice without close() returns Err
/// - Read-only: Phase 107 focuses on read operations only
/// - Phase 111: Supports "r", "w", "a" modes
pub struct Ring0FsFileIo {
ring0: Arc<Ring0Context>,
/// Current opened file path (None if no file is open)
path: RwLock<Option<String>>,
/// File mode ("r", "w", "a")
mode: RwLock<Option<String>>,
}
impl Ring0FsFileIo {
@ -33,6 +35,27 @@ impl Ring0FsFileIo {
Self {
ring0,
path: RwLock::new(None),
mode: RwLock::new(None),
}
}
/// Set file mode (internal helper for FileHandleBox)
pub fn set_mode(&self, mode: String) {
*self.mode.write().unwrap() = Some(mode);
}
/// Get metadata (internal helper for FileHandleBox)
pub fn metadata(&self) -> FileResult<crate::runtime::ring0::FsMetadata> {
let current_path = self.path.read().unwrap();
match current_path.as_ref() {
Some(path) => {
let path_obj = Path::new(path);
self.ring0.fs.metadata(path_obj)
.map_err(|e| FileError::Io(format!("Metadata failed: {}", e)))
}
None => {
Err(FileError::Io("No file path set. Call open() first.".to_string()))
}
}
}
}
@ -82,25 +105,57 @@ impl FileIo for Ring0FsFileIo {
fn write(&self, text: &str) -> FileResult<()> {
let current_path = self.path.read().unwrap();
let current_mode = self.mode.read().unwrap();
match current_path.as_ref() {
Some(path) => {
// Delegate to Ring0.FsApi (truncate mode: overwrite existing file)
match (current_path.as_ref(), current_mode.as_ref()) {
(Some(path), Some(mode)) => {
let path_obj = Path::new(path);
self.ring0.fs.write_all(path_obj, text.as_bytes())
.map_err(|e| FileError::Io(format!("Write failed: {}", e)))
// Mode-based write behavior (Phase 111)
match mode.as_str() {
"w" => {
// Truncate mode: overwrite existing file
self.ring0.fs.write_all(path_obj, text.as_bytes())
.map_err(|e| FileError::Io(format!("Write failed: {}", e)))
}
"a" => {
// Append mode: append to end of file
self.ring0.fs.append_all(path_obj, text.as_bytes())
.map_err(|e| FileError::Io(format!("Append failed: {}", e)))
}
"r" => {
// Read-only mode: cannot write
Err(FileError::Unsupported(
"Cannot write in read-only mode".to_string()
))
}
_ => {
Err(FileError::Unsupported(
format!("Unsupported mode: {}", mode)
))
}
}
}
None => {
(None, _) => {
Err(FileError::Io("No file is currently open. Call open() first.".to_string()))
}
(Some(_), None) => {
Err(FileError::Io("File mode not set. Internal error.".to_string()))
}
}
}
fn close(&self) -> FileResult<()> {
let mut current_path = self.path.write().unwrap();
let mut current_mode = self.mode.write().unwrap();
*current_path = None;
*current_mode = None;
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
#[cfg(test)]

View File

@ -182,6 +182,20 @@ impl FsApi for StdFs {
.map_err(|e| IoError::WriteFailed(format!("write({}): {}", path.display(), e)))
}
fn append_all(&self, path: &Path, data: &[u8]) -> Result<(), IoError> {
use std::fs::OpenOptions;
use std::io::Write;
let mut file = OpenOptions::new()
.create(true) // 存在しなければ作成
.append(true) // append モードで開く
.open(path)
.map_err(|e| IoError::WriteFailed(format!("append_all({}): {}", path.display(), e)))?;
file.write_all(data)
.map_err(|e| IoError::WriteFailed(format!("append write({}): {}", path.display(), e)))
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}

View File

@ -105,6 +105,12 @@ pub trait FsApi: Send + Sync {
/// ファイルに書き込む
fn write_all(&self, path: &Path, data: &[u8]) -> Result<(), IoError>;
/// ファイルに追記append
///
/// ファイルが存在しない場合は新規作成、存在する場合は末尾に追記。
/// Phase 111: write_all と対称的に提供。
fn append_all(&self, path: &Path, data: &[u8]) -> Result<(), IoError>;
/// パスが存在するか確認
fn exists(&self, path: &Path) -> bool;