rbcsv 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rust-analyzer.json +8 -0
- data/CHANGELOG.md +44 -0
- data/Cargo.lock +123 -45
- data/DEVELOPMENT.md +467 -129
- data/README.md +66 -5
- data/ext/rbcsv/Cargo.toml +6 -2
- data/ext/rbcsv/src/error.rs +101 -0
- data/ext/rbcsv/src/lib.rs +14 -143
- data/ext/rbcsv/src/parser.rs +306 -0
- data/ext/rbcsv/src/ruby_api.rs +111 -0
- data/lib/rbcsv/version.rb +1 -1
- data/test_write_functionality.rb +225 -0
- metadata +6 -1
data/README.md
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# RbCsv
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/r_csv`. To experiment with that code, run `bin/console` for an interactive prompt.
|
|
3
|
+
A fast CSV processing library for Ruby, built with Rust for high performance. RbCsv provides simple, efficient methods for parsing CSV strings, reading CSV files, and writing CSV data to files.
|
|
6
4
|
|
|
7
5
|
## Installation
|
|
8
6
|
|
|
@@ -22,7 +20,70 @@ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
|
22
20
|
|
|
23
21
|
## Usage
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
### Parsing CSV strings
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require 'rbcsv'
|
|
27
|
+
|
|
28
|
+
# Parse CSV string
|
|
29
|
+
csv_data = "name,age,city\nAlice,25,Tokyo\nBob,30,Osaka"
|
|
30
|
+
result = RbCsv.parse(csv_data)
|
|
31
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"], ["Bob", "30", "Osaka"]]
|
|
32
|
+
|
|
33
|
+
# Parse CSV with automatic whitespace trimming
|
|
34
|
+
csv_with_spaces = " name , age , city \n Alice , 25 , Tokyo "
|
|
35
|
+
result = RbCsv.parse!(csv_with_spaces)
|
|
36
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"]]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Reading CSV files
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# Read CSV file
|
|
43
|
+
result = RbCsv.read("data.csv")
|
|
44
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"], ["Bob", "30", "Osaka"]]
|
|
45
|
+
|
|
46
|
+
# Read CSV file with automatic whitespace trimming
|
|
47
|
+
result = RbCsv.read!("data_with_spaces.csv")
|
|
48
|
+
# => [["name", "age", "city"], ["Alice", "25", "Tokyo"], ["Bob", "30", "Osaka"]]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Writing CSV files
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# Prepare data
|
|
55
|
+
data = [
|
|
56
|
+
["name", "age", "city"],
|
|
57
|
+
["Alice", "25", "Tokyo"],
|
|
58
|
+
["Bob", "30", "Osaka"]
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
# Write CSV data to file
|
|
62
|
+
RbCsv.write("output.csv", data)
|
|
63
|
+
# Creates a file with:
|
|
64
|
+
# name,age,city
|
|
65
|
+
# Alice,25,Tokyo
|
|
66
|
+
# Bob,30,Osaka
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Error Handling
|
|
70
|
+
|
|
71
|
+
RbCsv provides detailed error messages for various scenarios:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# Empty data
|
|
75
|
+
RbCsv.write("output.csv", [])
|
|
76
|
+
# => RuntimeError: Invalid Data Error: CSV data is empty
|
|
77
|
+
|
|
78
|
+
# Inconsistent field count
|
|
79
|
+
data = [["name", "age"], ["Alice", "25", "Tokyo"]] # 3 fields in second row
|
|
80
|
+
RbCsv.write("output.csv", data)
|
|
81
|
+
# => RuntimeError: Invalid Data Error: Field count mismatch at line 2: expected 2 fields, got 3 fields
|
|
82
|
+
|
|
83
|
+
# File not found
|
|
84
|
+
RbCsv.read("nonexistent.csv")
|
|
85
|
+
# => RuntimeError: IO Error: File not found: nonexistent.csv
|
|
86
|
+
```
|
|
26
87
|
|
|
27
88
|
## Development
|
|
28
89
|
|
data/ext/rbcsv/Cargo.toml
CHANGED
|
@@ -11,5 +11,9 @@ crate-type = ["cdylib"]
|
|
|
11
11
|
|
|
12
12
|
[dependencies]
|
|
13
13
|
csv = "1.3.1"
|
|
14
|
-
magnus = { version = "0.
|
|
15
|
-
rb-sys = { version = "0.9", features = ["link-ruby"] }
|
|
14
|
+
magnus = { version = "0.8.1" }
|
|
15
|
+
# rb-sys = { version = "0.9", features = ["link-ruby"] }
|
|
16
|
+
log = "0.4"
|
|
17
|
+
|
|
18
|
+
[dev-dependencies]
|
|
19
|
+
env_logger = "0.10"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
use std::error::Error as StdError;
|
|
2
|
+
use std::fmt;
|
|
3
|
+
|
|
4
|
+
#[derive(Debug)]
|
|
5
|
+
pub struct CsvError {
|
|
6
|
+
message: String,
|
|
7
|
+
kind: ErrorKind,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#[derive(Debug)]
|
|
11
|
+
pub enum ErrorKind {
|
|
12
|
+
// IO関連エラー
|
|
13
|
+
Io,
|
|
14
|
+
// CSV解析エラー
|
|
15
|
+
Parse,
|
|
16
|
+
// UTF-8エンコーディングエラー
|
|
17
|
+
Encoding,
|
|
18
|
+
// フィールド数の不一致
|
|
19
|
+
FieldCountMismatch,
|
|
20
|
+
// 空のCSVデータ
|
|
21
|
+
EmptyData,
|
|
22
|
+
// 書き込み権限エラー
|
|
23
|
+
WritePermission,
|
|
24
|
+
// 無効なデータエラー
|
|
25
|
+
InvalidData,
|
|
26
|
+
// その他のエラー
|
|
27
|
+
#[allow(dead_code)]
|
|
28
|
+
Other,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
impl fmt::Display for CsvError {
|
|
32
|
+
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
33
|
+
match self.kind {
|
|
34
|
+
ErrorKind::Io => write!(f, "IO Error: {}", self.message),
|
|
35
|
+
ErrorKind::Parse => write!(f, "Parse Error: {}", self.message),
|
|
36
|
+
ErrorKind::Encoding => write!(f, "Encoding Error: {}", self.message),
|
|
37
|
+
ErrorKind::FieldCountMismatch => write!(f, "Field Count Mismatch: {}", self.message),
|
|
38
|
+
ErrorKind::EmptyData => write!(f, "Empty Data: {}", self.message),
|
|
39
|
+
ErrorKind::WritePermission => write!(f, "Write Permission Error: {}", self.message),
|
|
40
|
+
ErrorKind::InvalidData => write!(f, "Invalid Data Error: {}", self.message),
|
|
41
|
+
ErrorKind::Other => write!(f, "Error: {}", self.message),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
impl StdError for CsvError {}
|
|
47
|
+
|
|
48
|
+
impl CsvError {
|
|
49
|
+
pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
|
50
|
+
CsvError {
|
|
51
|
+
message: message.into(),
|
|
52
|
+
kind,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
pub fn io(message: impl Into<String>) -> Self {
|
|
57
|
+
Self::new(ErrorKind::Io, message)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub fn parse(message: impl Into<String>) -> Self {
|
|
61
|
+
Self::new(ErrorKind::Parse, message)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub fn encoding(message: impl Into<String>) -> Self {
|
|
65
|
+
Self::new(ErrorKind::Encoding, message)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[allow(dead_code)]
|
|
69
|
+
pub fn field_count_mismatch(expected: usize, actual: usize) -> Self {
|
|
70
|
+
Self::new(
|
|
71
|
+
ErrorKind::FieldCountMismatch,
|
|
72
|
+
format!("Expected {} fields, but got {}", expected, actual),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pub fn empty_data() -> Self {
|
|
77
|
+
Self::new(ErrorKind::EmptyData, "CSV data is empty")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
pub fn write_permission(message: impl Into<String>) -> Self {
|
|
81
|
+
Self::new(ErrorKind::WritePermission, message)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pub fn invalid_data(message: impl Into<String>) -> Self {
|
|
85
|
+
Self::new(ErrorKind::InvalidData, message)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// csv crateのエラーからの変換
|
|
90
|
+
impl From<csv::Error> for CsvError {
|
|
91
|
+
fn from(err: csv::Error) -> Self {
|
|
92
|
+
match err.kind() {
|
|
93
|
+
csv::ErrorKind::Io(_) => CsvError::io(err.to_string()),
|
|
94
|
+
csv::ErrorKind::Utf8 { .. } => CsvError::encoding(err.to_string()),
|
|
95
|
+
csv::ErrorKind::UnequalLengths { .. } => {
|
|
96
|
+
CsvError::new(ErrorKind::FieldCountMismatch, err.to_string())
|
|
97
|
+
}
|
|
98
|
+
_ => CsvError::parse(err.to_string()),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
data/ext/rbcsv/src/lib.rs
CHANGED
|
@@ -1,149 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
mod error;
|
|
2
|
+
mod parser;
|
|
3
|
+
mod ruby_api;
|
|
2
4
|
|
|
3
|
-
use
|
|
4
|
-
|
|
5
|
-
use magnus::{Error as MagnusError, Object};
|
|
6
|
-
|
|
7
|
-
#[derive(Debug)]
|
|
8
|
-
struct CustomError {
|
|
9
|
-
message: String,
|
|
10
|
-
kind: ErrorKind,
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
#[derive(Debug)]
|
|
14
|
-
enum ErrorKind {
|
|
15
|
-
Io,
|
|
16
|
-
_Parse,
|
|
17
|
-
_Network,
|
|
18
|
-
_Other,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
impl fmt::Display for CustomError {
|
|
22
|
-
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
23
|
-
write!(f, "{:?}: {}", self.kind, self.message)
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
impl StdError for CustomError {}
|
|
28
|
-
|
|
29
|
-
impl CustomError {
|
|
30
|
-
fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
|
|
31
|
-
CustomError {
|
|
32
|
-
message: message.into(),
|
|
33
|
-
kind,
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
type Error = CustomError;
|
|
39
|
-
|
|
40
|
-
fn escape_sanitize(s: String) -> String {
|
|
41
|
-
s.replace("\\n", "\n")
|
|
42
|
-
.replace("\\r", "\r")
|
|
43
|
-
.replace("\\t", "\t")
|
|
44
|
-
.replace("\\\"", "\"")
|
|
45
|
-
.replace("\\\\", "\\")
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
fn parse(s: String) -> Result<Vec<Vec<String>>, MagnusError> {
|
|
49
|
-
parse_with_options(s, false) // デフォルトはトリムなし
|
|
50
|
-
.map_err(|e| MagnusError::new(magnus::exception::runtime_error(), e.to_string()))
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
fn parse_with_trim(s: String, trim_whitespace: bool) -> Result<Vec<Vec<String>>, MagnusError> {
|
|
54
|
-
parse_with_options(s, trim_whitespace)
|
|
55
|
-
.map_err(|e| MagnusError::new(magnus::exception::runtime_error(), e.to_string()))
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
fn parse_with_options(s: String, trim_whitespace: bool) -> Result<Vec<Vec<String>>, Error> {
|
|
59
|
-
// エスケープシーケンスを実際の文字に変換
|
|
60
|
-
let processed = escape_sanitize(s);
|
|
61
|
-
|
|
62
|
-
let mut reader = csv::ReaderBuilder::new()
|
|
63
|
-
.has_headers(false) // ヘッダーを無効にして、すべての行 を読み込む
|
|
64
|
-
.trim(if trim_whitespace { csv::Trim::All } else { csv::Trim::None })
|
|
65
|
-
.from_reader(processed.as_bytes());
|
|
66
|
-
|
|
67
|
-
let mut records = Vec::new();
|
|
68
|
-
|
|
69
|
-
for result in reader.records() {
|
|
70
|
-
match result {
|
|
71
|
-
Ok(record) => {
|
|
72
|
-
let row: Vec<String> = record.iter().map(|field| field.to_string()).collect();
|
|
73
|
-
println!("row {:?}", row);
|
|
74
|
-
records.push(row);
|
|
75
|
-
}
|
|
76
|
-
Err(_e) => {
|
|
77
|
-
return Err(Error::new(ErrorKind::Io, "File not found"));
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
Ok(records)
|
|
83
|
-
}
|
|
5
|
+
use magnus::{Object, Ruby};
|
|
6
|
+
use ruby_api::{parse, parse_trim, read, read_trim, write};
|
|
84
7
|
|
|
85
8
|
#[magnus::init]
|
|
86
|
-
fn init() {
|
|
87
|
-
let module =
|
|
88
|
-
module.define_singleton_method("parse", magnus::function!(parse, 1)).expect("Failed to define parse method");
|
|
89
|
-
module.define_singleton_method("parse_with_trim", magnus::function!(parse_with_trim, 2)).expect("Failed to define parse_with_trim method");
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
#[cfg(test)]
|
|
93
|
-
mod tests {
|
|
94
|
-
|
|
95
|
-
use super::*;
|
|
96
|
-
|
|
97
|
-
#[test]
|
|
98
|
-
fn test_parse_simple() {
|
|
99
|
-
let csv_data = "a,b\n1,2";
|
|
100
|
-
let result = parse(csv_data.to_string());
|
|
101
|
-
|
|
102
|
-
assert!(result.is_ok());
|
|
9
|
+
fn init(ruby: &Ruby) -> Result<(), magnus::Error> {
|
|
10
|
+
let module = ruby.define_module("RbCsv")?;
|
|
103
11
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
12
|
+
module.define_singleton_method("parse", magnus::function!(parse, 1))?;
|
|
13
|
+
module.define_singleton_method("parse!", magnus::function!(parse_trim, 1))?;
|
|
14
|
+
module.define_singleton_method("read", magnus::function!(read, 1))?;
|
|
15
|
+
module.define_singleton_method("read!", magnus::function!(read_trim, 1))?;
|
|
16
|
+
module.define_singleton_method("write", magnus::function!(write, 2))?;
|
|
109
17
|
|
|
110
|
-
|
|
111
|
-
/// - シングルクォートの文字列のようなエスケープされた改行をテスト
|
|
112
|
-
fn test_parse_with_escaped_newline() {
|
|
113
|
-
let csv_data = "a,b\\n1,2";
|
|
114
|
-
let result = parse(csv_data.to_string());
|
|
115
|
-
|
|
116
|
-
assert!(result.is_ok());
|
|
117
|
-
|
|
118
|
-
let records = result.unwrap();
|
|
119
|
-
assert_eq!(records.len(), 2);
|
|
120
|
-
assert_eq!(records[0], vec!["a", "b"]);
|
|
121
|
-
assert_eq!(records[1], vec!["1", "2"]);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
#[test]
|
|
125
|
-
fn test_parse_with_trim() {
|
|
126
|
-
let csv_data = " a , b \n 1 , 2 ";
|
|
127
|
-
let result = parse_with_trim(csv_data.to_string(), true);
|
|
128
|
-
|
|
129
|
-
assert!(result.is_ok());
|
|
130
|
-
|
|
131
|
-
let records = result.unwrap();
|
|
132
|
-
assert_eq!(records.len(), 2);
|
|
133
|
-
assert_eq!(records[0], vec!["a", "b"]);
|
|
134
|
-
assert_eq!(records[1], vec!["1", "2"]);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
#[test]
|
|
138
|
-
fn test_parse_without_trim() {
|
|
139
|
-
let csv_data = " a , b \n 1 , 2 ";
|
|
140
|
-
let result = parse_with_trim(csv_data.to_string(), false);
|
|
141
|
-
|
|
142
|
-
assert!(result.is_ok());
|
|
143
|
-
|
|
144
|
-
let records = result.unwrap();
|
|
145
|
-
assert_eq!(records.len(), 2);
|
|
146
|
-
assert_eq!(records[0], vec![" a ", " b "]);
|
|
147
|
-
assert_eq!(records[1], vec![" 1 ", " 2 "]);
|
|
148
|
-
}
|
|
18
|
+
Ok(())
|
|
149
19
|
}
|
|
20
|
+
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
use crate::error::{CsvError, ErrorKind};
|
|
2
|
+
use std::fs;
|
|
3
|
+
use std::path::Path;
|
|
4
|
+
|
|
5
|
+
/// CSV解析のオプション設定
|
|
6
|
+
#[derive(Debug, Clone)]
|
|
7
|
+
#[allow(dead_code)]
|
|
8
|
+
pub struct CsvParseOptions {
|
|
9
|
+
pub trim: bool,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl Default for CsvParseOptions {
|
|
13
|
+
fn default() -> Self {
|
|
14
|
+
Self {
|
|
15
|
+
trim: false,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// エスケープシーケンスを実際の文字に変換
|
|
21
|
+
pub fn escape_sanitize(s: &str) -> String {
|
|
22
|
+
s.replace("\\n", "\n")
|
|
23
|
+
.replace("\\r", "\r")
|
|
24
|
+
.replace("\\t", "\t")
|
|
25
|
+
.replace("\\\"", "\"")
|
|
26
|
+
.replace("\\\\", "\\")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// 基本的なCSVパース処理
|
|
30
|
+
pub fn parse_csv_core(input: &str, trim_config: csv::Trim) -> Result<Vec<Vec<String>>, CsvError> {
|
|
31
|
+
// 空のデータチェック
|
|
32
|
+
if input.trim().is_empty() {
|
|
33
|
+
return Err(CsvError::empty_data());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// CSV crate に任せて適切なパースを行う(escape_sanitize は削除)
|
|
37
|
+
let mut reader = csv::ReaderBuilder::new()
|
|
38
|
+
.has_headers(false) // ヘッダーを無効にして、すべての行を読み込む
|
|
39
|
+
.trim(trim_config)
|
|
40
|
+
.from_reader(input.as_bytes());
|
|
41
|
+
|
|
42
|
+
let mut records = Vec::new();
|
|
43
|
+
|
|
44
|
+
for (line_num, result) in reader.records().enumerate() {
|
|
45
|
+
match result {
|
|
46
|
+
Ok(record) => {
|
|
47
|
+
let row: Vec<String> = record.iter().map(|field| field.to_string()).collect();
|
|
48
|
+
records.push(row);
|
|
49
|
+
}
|
|
50
|
+
Err(e) => {
|
|
51
|
+
// フィールド数不一致エラーを詳細化
|
|
52
|
+
if let csv::ErrorKind::UnequalLengths { expected_len, len, .. } = e.kind() {
|
|
53
|
+
let error_msg = format!(
|
|
54
|
+
"Field count mismatch at line {}: expected {} fields, got {} fields",
|
|
55
|
+
line_num + 1,
|
|
56
|
+
expected_len,
|
|
57
|
+
len
|
|
58
|
+
);
|
|
59
|
+
return Err(CsvError::new(ErrorKind::FieldCountMismatch, error_msg));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// その他のcsvエラーを自動変換
|
|
63
|
+
return Err(CsvError::from(e));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if records.is_empty() {
|
|
69
|
+
return Err(CsvError::empty_data());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
Ok(records)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// オプション設定を使ったCSV解析(文字列用)
|
|
76
|
+
pub fn _parse_csv_with_options(input: &str, options: &CsvParseOptions) -> Result<Vec<Vec<String>>, CsvError> {
|
|
77
|
+
let trim_config = if options.trim { csv::Trim::All } else { csv::Trim::None };
|
|
78
|
+
parse_csv_core(input, trim_config)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// オプション設定を使ったCSV解析(ファイル用)
|
|
82
|
+
pub fn _parse_csv_file_with_options(file_path: &str, options: &CsvParseOptions) -> Result<Vec<Vec<String>>, CsvError> {
|
|
83
|
+
let trim_config = if options.trim { csv::Trim::All } else { csv::Trim::None };
|
|
84
|
+
parse_csv_file(file_path, trim_config)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// ファイルからCSVを読み込んでパースする
|
|
88
|
+
pub fn parse_csv_file(file_path: &str, trim_config: csv::Trim) -> Result<Vec<Vec<String>>, CsvError> {
|
|
89
|
+
// ファイルパスの検証
|
|
90
|
+
let path = Path::new(file_path);
|
|
91
|
+
if !path.exists() {
|
|
92
|
+
return Err(CsvError::io(format!("File not found: {}", file_path)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if !path.is_file() {
|
|
96
|
+
return Err(CsvError::io(format!("Path is not a file: {}", file_path)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ファイル読み込み
|
|
100
|
+
let content = match fs::read_to_string(path) {
|
|
101
|
+
Ok(content) => content,
|
|
102
|
+
Err(e) => {
|
|
103
|
+
return Err(CsvError::io(format!("Failed to read file '{}': {}", file_path, e)));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// CSVパース
|
|
108
|
+
parse_csv_core(&content, trim_config)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[cfg(test)]
|
|
112
|
+
mod tests {
|
|
113
|
+
use super::*;
|
|
114
|
+
|
|
115
|
+
#[test]
|
|
116
|
+
fn test_escape_sanitize() {
|
|
117
|
+
let input = "Hello\\nWorld\\t\\\"Test\\\"\\\\End";
|
|
118
|
+
let expected = "Hello\nWorld\t\"Test\"\\End";
|
|
119
|
+
assert_eq!(escape_sanitize(input), expected);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#[test]
|
|
123
|
+
fn test_parse_csv_core_basic() {
|
|
124
|
+
let csv_data = "a,b,c\n1,2,3";
|
|
125
|
+
let result = parse_csv_core(csv_data, csv::Trim::None);
|
|
126
|
+
|
|
127
|
+
assert!(result.is_ok());
|
|
128
|
+
let records = result.unwrap();
|
|
129
|
+
assert_eq!(records.len(), 2);
|
|
130
|
+
assert_eq!(records[0], vec!["a", "b", "c"]);
|
|
131
|
+
assert_eq!(records[1], vec!["1", "2", "3"]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[test]
|
|
135
|
+
fn test_parse_csv_file_not_found() {
|
|
136
|
+
let result = parse_csv_file("non_existent_file.csv", csv::Trim::None);
|
|
137
|
+
|
|
138
|
+
assert!(result.is_err());
|
|
139
|
+
if let Err(e) = result {
|
|
140
|
+
assert!(e.to_string().contains("File not found"));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#[test]
|
|
145
|
+
fn test_parse_csv_file_directory() {
|
|
146
|
+
// ディレクトリを指定した場合のテスト
|
|
147
|
+
let result = parse_csv_file(".", csv::Trim::None);
|
|
148
|
+
|
|
149
|
+
assert!(result.is_err());
|
|
150
|
+
if let Err(e) = result {
|
|
151
|
+
assert!(e.to_string().contains("Path is not a file"));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[test]
|
|
156
|
+
fn test_parse_csv_file_with_temp_file() {
|
|
157
|
+
use std::io::Write;
|
|
158
|
+
use std::fs::File;
|
|
159
|
+
|
|
160
|
+
// 一時ファイルを作成
|
|
161
|
+
let temp_path = "/tmp/test_csv_file.csv";
|
|
162
|
+
let csv_content = "name,age,city\nAlice,25,Tokyo\nBob,30,Osaka";
|
|
163
|
+
|
|
164
|
+
{
|
|
165
|
+
let mut file = File::create(temp_path).expect("Failed to create temp file");
|
|
166
|
+
file.write_all(csv_content.as_bytes()).expect("Failed to write to temp file");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ファイルからCSVを読み込み
|
|
170
|
+
let result = parse_csv_file(temp_path, csv::Trim::None);
|
|
171
|
+
|
|
172
|
+
// クリーンアップ
|
|
173
|
+
let _ = std::fs::remove_file(temp_path);
|
|
174
|
+
|
|
175
|
+
// 結果を検証
|
|
176
|
+
assert!(result.is_ok());
|
|
177
|
+
let records = result.unwrap();
|
|
178
|
+
assert_eq!(records.len(), 3);
|
|
179
|
+
assert_eq!(records[0], vec!["name", "age", "city"]);
|
|
180
|
+
assert_eq!(records[1], vec!["Alice", "25", "Tokyo"]);
|
|
181
|
+
assert_eq!(records[2], vec!["Bob", "30", "Osaka"]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#[test]
|
|
185
|
+
fn test_write_csv_file_basic() {
|
|
186
|
+
|
|
187
|
+
let temp_path = "/tmp/test_write_csv.csv";
|
|
188
|
+
let test_data = vec![
|
|
189
|
+
vec!["name".to_string(), "age".to_string(), "city".to_string()],
|
|
190
|
+
vec!["Alice".to_string(), "25".to_string(), "Tokyo".to_string()],
|
|
191
|
+
vec!["Bob".to_string(), "30".to_string(), "Osaka".to_string()],
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
// ファイルに書き込み
|
|
195
|
+
let result = write_csv_file(temp_path, &test_data);
|
|
196
|
+
assert!(result.is_ok(), "Write should succeed");
|
|
197
|
+
|
|
198
|
+
// 書き込んだファイルを読み込んで検証
|
|
199
|
+
let content = std::fs::read_to_string(temp_path).expect("Failed to read written file");
|
|
200
|
+
let expected = "name,age,city\nAlice,25,Tokyo\nBob,30,Osaka\n";
|
|
201
|
+
assert_eq!(content, expected);
|
|
202
|
+
|
|
203
|
+
// クリーンアップ
|
|
204
|
+
let _ = std::fs::remove_file(temp_path);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#[test]
|
|
208
|
+
fn test_write_csv_file_empty_data() {
|
|
209
|
+
let temp_path = "/tmp/test_write_empty.csv";
|
|
210
|
+
let empty_data: Vec<Vec<String>> = vec![];
|
|
211
|
+
|
|
212
|
+
let result = write_csv_file(temp_path, &empty_data);
|
|
213
|
+
assert!(result.is_err());
|
|
214
|
+
if let Err(e) = result {
|
|
215
|
+
assert!(e.to_string().contains("CSV data is empty"));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[test]
|
|
220
|
+
fn test_write_csv_file_field_count_mismatch() {
|
|
221
|
+
let temp_path = "/tmp/test_write_mismatch.csv";
|
|
222
|
+
let inconsistent_data = vec![
|
|
223
|
+
vec!["name".to_string(), "age".to_string()],
|
|
224
|
+
vec!["Alice".to_string(), "25".to_string(), "Tokyo".to_string()], // 3 fields instead of 2
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
let result = write_csv_file(temp_path, &inconsistent_data);
|
|
228
|
+
assert!(result.is_err());
|
|
229
|
+
if let Err(e) = result {
|
|
230
|
+
assert!(e.to_string().contains("Field count mismatch"));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#[test]
|
|
235
|
+
fn test_write_csv_file_permission_denied() {
|
|
236
|
+
// 書き込み権限のないパスをテスト(rootディレクトリ)
|
|
237
|
+
let result = write_csv_file("/root/test.csv", &vec![vec!["test".to_string()]]);
|
|
238
|
+
assert!(result.is_err());
|
|
239
|
+
if let Err(e) = result {
|
|
240
|
+
// Permission deniedまたはParent directory does not existのいずれかになる
|
|
241
|
+
let error_msg = e.to_string();
|
|
242
|
+
assert!(error_msg.contains("Permission denied") || error_msg.contains("Parent directory does not exist"));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// CSVデータをファイルに書き込む
|
|
248
|
+
pub fn write_csv_file(file_path: &str, data: &[Vec<String>]) -> Result<(), CsvError> {
|
|
249
|
+
// データ検証:空配列チェック
|
|
250
|
+
if data.is_empty() {
|
|
251
|
+
return Err(CsvError::invalid_data("CSV data is empty"));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// データ検証:各行のフィールド数一貫性チェック
|
|
255
|
+
if data.len() > 1 {
|
|
256
|
+
let expected_len = data[0].len();
|
|
257
|
+
for (line_num, row) in data.iter().enumerate() {
|
|
258
|
+
if row.len() != expected_len {
|
|
259
|
+
let error_msg = format!(
|
|
260
|
+
"Field count mismatch at line {}: expected {} fields, got {} fields",
|
|
261
|
+
line_num + 1,
|
|
262
|
+
expected_len,
|
|
263
|
+
row.len()
|
|
264
|
+
);
|
|
265
|
+
return Err(CsvError::invalid_data(error_msg));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ファイルパス検証:親ディレクトリの存在確認
|
|
271
|
+
let path = Path::new(file_path);
|
|
272
|
+
if let Some(parent) = path.parent() {
|
|
273
|
+
if !parent.exists() {
|
|
274
|
+
return Err(CsvError::io(format!("Parent directory does not exist: {}", parent.display())));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// CSV Writer作成とデータ書き込み
|
|
279
|
+
let file = match fs::File::create(path) {
|
|
280
|
+
Ok(file) => file,
|
|
281
|
+
Err(e) => {
|
|
282
|
+
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
|
283
|
+
return Err(CsvError::write_permission(format!("Permission denied: {}", file_path)));
|
|
284
|
+
}
|
|
285
|
+
return Err(CsvError::io(format!("Failed to create file '{}': {}", file_path, e)));
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
let mut writer = csv::WriterBuilder::new()
|
|
290
|
+
.has_headers(false)
|
|
291
|
+
.from_writer(file);
|
|
292
|
+
|
|
293
|
+
// データ書き込み
|
|
294
|
+
for row in data {
|
|
295
|
+
if let Err(e) = writer.write_record(row) {
|
|
296
|
+
return Err(CsvError::from(e));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ファイルフラッシュ:データの確実な書き込み保証
|
|
301
|
+
if let Err(e) = writer.flush() {
|
|
302
|
+
return Err(CsvError::io(format!("Failed to flush data to file '{}': {}", file_path, e)));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
Ok(())
|
|
306
|
+
}
|